Skip to main content

egui_charts/ui/dialogs/chart_settings/
mod.rs

1//! Tabbed dialog for editing chart display settings.
2//!
3//! Open [`SettingsDialog`] with the chart's settings and apply the returned
4//! [`SettingsAction`] in the host.
5
6use crate::localization as l10n;
7use crate::theme::ThemeExt;
8use crate::widget::settings::ChartSettingsState;
9mod tabs;
10
11use crate::icons::icons as embedded_icons;
12use crate::services::StoreKind;
13use crate::theme::Theme;
14use crate::tokens::DESIGN_TOKENS;
15use crate::ui::Control;
16use crate::ui::dialogs::templates::TemplateStore;
17use crate::ui::top_toolbar::components::settings_menu::{
18    actions::SettingsAction, config::SettingsDialogConfig, types::SettingsTab,
19};
20use crate::ui_kit::ColorPickerState;
21use crate::ui_kit::dialog::DialogFooter;
22use crate::ui_kit::modal::Modal;
23use crate::ui_kit::sidebar_layout::{SidebarLayout, SidebarTab};
24use egui::{Color32, Context, Rect, Response, RichText, Ui, Vec2};
25use std::collections::HashSet;
26
27// ============================================================================
28// Settings Dialog (Modal)
29// ============================================================================
30
31/// Width of the tab sidebar, before it yields to narrow windows.
32const SIDEBAR_WIDTH: f32 = 206.0;
33
34/// Outer height of the tallest tab, whose centred top edge every tab keeps.
35const TALLEST_TAB_HEIGHT: f32 = 840.0;
36
37/// Modal settings dialog with tabbed interface.
38///
39/// Edits chart display settings, event category visibility and the host trading and
40/// alert-overlay preferences.
41pub struct SettingsDialog {
42    /// Whether the dialog is currently open
43    pub is_open: bool,
44    active_tab: SettingsTab,
45    settings: ChartSettingsState,
46    /// Current template name (empty if using default/unsaved settings)
47    template_name: String,
48    /// Whether the template dropdown menu is open
49    template_menu_open: bool,
50    /// Host-readable chart templates and their optional storage connection.
51    pub templates: TemplateStore<ChartSettingsState>,
52    save_name: Option<String>,
53    timezones: Vec<String>,
54    /// Dialog styling configuration (width, height overrides)
55    config: SettingsDialogConfig,
56    color_picker_states: ColorPickerStates,
57    margin_top_str: String,
58    margin_bottom_str: String,
59    margin_right_str: String,
60    /// Controls the host hid.
61    hidden: HashSet<Control>,
62    /// The local sounds the host can play, by name, offered on the Trading and Alerts tabs. The host
63    /// plays the chosen one; see
64    /// [`Trading::take_execution_sound`](crate::ui::trading::Trading::take_execution_sound).
65    /// Without any, the tab offers no choice.
66    pub execution_sounds: Vec<String>,
67    sound_preview: Option<(String, f32)>,
68    /// Heatmap, book, individual orders and bubbles offered by the host.
69    order_flow_layers: [bool; 4],
70}
71
72#[derive(Clone, Default)]
73struct ColorPickerStates {
74    body_up: ColorPickerState,
75    body_down: ColorPickerState,
76    border_up: ColorPickerState,
77    border_down: ColorPickerState,
78    wick_up: ColorPickerState,
79    wick_down: ColorPickerState,
80    background: ColorPickerState,
81    background_gradient_top: ColorPickerState,
82    background_gradient_bottom: ColorPickerState,
83    grid_h: ColorPickerState,
84    grid_v: ColorPickerState,
85    crosshair: ColorPickerState,
86    watermark: ColorPickerState,
87    scales_text: ColorPickerState,
88    scales_lines: ColorPickerState,
89    alert: ColorPickerState,
90    symbol_value: ColorPickerState,
91    study_value: ColorPickerState,
92    extended_value: ColorPickerState,
93}
94
95impl Default for SettingsDialog {
96    fn default() -> Self {
97        Self::new()
98    }
99}
100
101impl SettingsDialog {
102    /// Create a [`SettingsDialog`] with default settings.
103    pub fn new() -> Self {
104        let settings = ChartSettingsState::default();
105        let timezones: &[&str] = if cfg!(feature = "timezones") {
106            &[
107                "UTC",
108                "America/New_York",
109                "America/Chicago",
110                "America/Los_Angeles",
111                "Europe/London",
112                "Europe/Paris",
113                "Asia/Tokyo",
114                "Asia/Hong_Kong",
115                "Asia/Singapore",
116                "Australia/Sydney",
117            ]
118        } else {
119            &[
120                "UTC", "-08:00", "-06:00", "-05:00", "+01:00", "+08:00", "+09:00", "+10:00",
121            ]
122        };
123        Self {
124            is_open: false,
125            active_tab: SettingsTab::Symbol,
126            color_picker_states: ColorPickerStates {
127                body_up: ColorPickerState::new(settings.candle_colors.body_up),
128                body_down: ColorPickerState::new(settings.candle_colors.body_down),
129                border_up: ColorPickerState::new(settings.candle_colors.border_up),
130                border_down: ColorPickerState::new(settings.candle_colors.border_down),
131                wick_up: ColorPickerState::new(settings.candle_colors.wick_up),
132                wick_down: ColorPickerState::new(settings.candle_colors.wick_down),
133                background: ColorPickerState::new(settings.chart_basic_styles.background_color),
134                background_gradient_top: ColorPickerState::new(
135                    settings.chart_basic_styles.background_gradient_top,
136                ),
137                background_gradient_bottom: ColorPickerState::new(
138                    settings.chart_basic_styles.background_gradient_bottom,
139                ),
140                grid_h: ColorPickerState::new(settings.grid_lines.horizontal_color),
141                grid_v: ColorPickerState::new(settings.grid_lines.vertical_color),
142                crosshair: ColorPickerState::new(settings.crosshair.color),
143                watermark: ColorPickerState::new(settings.watermark.color),
144                scales_text: ColorPickerState::new(settings.scales_appearance.text_color),
145                scales_lines: ColorPickerState::new(settings.scales_appearance.lines_color),
146                alert: ColorPickerState::new(settings.alerts.alert_color),
147                symbol_value: ColorPickerState::new(
148                    settings
149                        .scales_and_lines
150                        .symbol_price_color
151                        .unwrap_or(settings.candle_colors.body_up),
152                ),
153                study_value: ColorPickerState::new(
154                    settings
155                        .scales_and_lines
156                        .study_value_color
157                        .unwrap_or(settings.scales_appearance.text_color),
158                ),
159                extended_value: ColorPickerState::new(
160                    settings.scales_and_lines.extended_session_price_color,
161                ),
162            },
163            margin_top_str: format!("{}", settings.margins.top_percent),
164            margin_bottom_str: format!("{}", settings.margins.bottom_percent),
165            margin_right_str: format!("{}", settings.margins.right_bars),
166            settings,
167            template_name: String::new(),
168            template_menu_open: false,
169            templates: TemplateStore::new(StoreKind::ChartTemplates),
170            save_name: None,
171            timezones: timezones.iter().map(|zone| (*zone).to_owned()).collect(),
172            config: SettingsDialogConfig::default(),
173            hidden: HashSet::new(),
174            execution_sounds: Vec::new(),
175            sound_preview: None,
176            order_flow_layers: [true; 4],
177        }
178    }
179
180    /// Shows or hides `control`: Templates (the template button and menu; Reset to defaults
181    /// then takes the button's place). The dialog ignores the other controls.
182    pub fn set_control_visible(&mut self, control: Control, visible: bool) {
183        crate::ui::app_state::set_control_visible(&mut self.hidden, control, visible);
184        if control == Control::Templates && !visible {
185            self.template_menu_open = false;
186        }
187    }
188
189    /// Restrict Order flow controls to services explicitly declared for the chart's instrument.
190    /// Pass [`Chart::feed_capabilities`](crate::Chart::feed_capabilities) and
191    /// [`Chart::instrument`](crate::Chart::instrument) again when they change. A missing instrument
192    /// or a non-positive, non-finite or missing tick size hides the tab. Before the first call,
193    /// all layers are offered. Hidden controls retain their saved values.
194    pub fn set_feed_capabilities(
195        &mut self,
196        capabilities: Option<&crate::data::FeedCapabilities>,
197        instrument: Option<&crate::model::Instrument>,
198    ) {
199        use crate::model::FeedService;
200        let declares = |service| {
201            instrument.is_some_and(|instrument| {
202                instrument
203                    .tick_size
204                    .is_some_and(|tick| tick.is_finite() && tick > 0.0)
205                    && capabilities
206                        .unwrap_or(&crate::data::FeedCapabilities::default())
207                        .declares(service, instrument)
208            })
209        };
210        let depth = declares(FeedService::Depth);
211        self.order_flow_layers = [
212            depth || declares(FeedService::DepthHistory),
213            depth,
214            depth && declares(FeedService::OrderDepth),
215            declares(FeedService::Trades) || declares(FeedService::LiveTrades),
216        ];
217        if !self.order_flow_layers.iter().any(|available| *available)
218            && self.active_tab == SettingsTab::OrderFlow
219        {
220            self.active_tab = SettingsTab::Symbol;
221        }
222    }
223
224    /// Open the dialog with the supplied state.
225    pub fn open(&mut self, settings: ChartSettingsState) {
226        self.is_open = true;
227        self.template_menu_open = false;
228        self.save_name = None;
229        self.sound_preview = None;
230        self.settings = settings;
231        self.active_tab = SettingsTab::Symbol;
232        self.update_color_pickers_from_settings();
233    }
234
235    /// Sync all color picker states from the current settings
236    fn update_color_pickers_from_settings(&mut self) {
237        self.color_picker_states.body_up.sel_color = self.settings.candle_colors.body_up;
238        self.color_picker_states.body_down.sel_color = self.settings.candle_colors.body_down;
239        self.color_picker_states.border_up.sel_color = self.settings.candle_colors.border_up;
240        self.color_picker_states.border_down.sel_color = self.settings.candle_colors.border_down;
241        self.color_picker_states.wick_up.sel_color = self.settings.candle_colors.wick_up;
242        self.color_picker_states.wick_down.sel_color = self.settings.candle_colors.wick_down;
243        self.color_picker_states.background.sel_color =
244            self.settings.chart_basic_styles.background_color;
245        self.color_picker_states.background_gradient_top.sel_color =
246            self.settings.chart_basic_styles.background_gradient_top;
247        self.color_picker_states
248            .background_gradient_bottom
249            .sel_color = self.settings.chart_basic_styles.background_gradient_bottom;
250        self.color_picker_states.grid_h.sel_color = self.settings.grid_lines.horizontal_color;
251        self.color_picker_states.grid_v.sel_color = self.settings.grid_lines.vertical_color;
252        self.color_picker_states.crosshair.sel_color = self.settings.crosshair.color;
253        self.color_picker_states.watermark.sel_color = self.settings.watermark.color;
254        self.color_picker_states.scales_text.sel_color = self.settings.scales_appearance.text_color;
255        self.color_picker_states.scales_lines.sel_color =
256            self.settings.scales_appearance.lines_color;
257
258        self.color_picker_states.alert.sel_color = self.settings.alerts.alert_color;
259        self.color_picker_states.symbol_value.sel_color = self
260            .settings
261            .scales_and_lines
262            .symbol_price_color
263            .unwrap_or(self.settings.candle_colors.body_up);
264        self.color_picker_states.study_value.sel_color = self
265            .settings
266            .scales_and_lines
267            .study_value_color
268            .unwrap_or(self.settings.scales_appearance.text_color);
269        self.color_picker_states.extended_value.sel_color =
270            self.settings.scales_and_lines.extended_session_price_color;
271
272        self.margin_top_str = format!("{}", self.settings.margins.top_percent);
273        self.margin_bottom_str = format!("{}", self.settings.margins.bottom_percent);
274        self.margin_right_str = format!("{}", self.settings.margins.right_bars);
275    }
276
277    /// Close the dialog.
278    pub fn close(&mut self) {
279        self.is_open = false;
280    }
281
282    /// Read the display settings being edited.
283    pub fn settings(&self) -> &ChartSettingsState {
284        &self.settings
285    }
286
287    /// Show the dialog and return the requested action.
288    pub fn show(&mut self, ctx: &Context, theme: &Theme) -> SettingsAction {
289        self.templates.poll(ctx);
290        let mut action = SettingsAction::None;
291
292        if !self.is_open {
293            return action;
294        }
295
296        let basic = &self.settings.chart_basic_styles;
297        for (picker, color, themed) in [
298            (
299                &mut self.color_picker_states.background,
300                basic.background_color,
301                theme.chart.bg,
302            ),
303            (
304                &mut self.color_picker_states.background_gradient_top,
305                basic.background_gradient_top,
306                theme.chart.bg,
307            ),
308            (
309                &mut self.color_picker_states.background_gradient_bottom,
310                basic.background_gradient_bottom,
311                theme.chart.bg_axis,
312            ),
313        ] {
314            picker.sel_color = if color == Color32::PLACEHOLDER {
315                themed
316            } else {
317                color
318            };
319        }
320
321        let mut is_open = self.is_open;
322        let fixed_height = (self.config.height > 0.0).then_some(self.config.height);
323        let height = fixed_height.unwrap_or(match self.active_tab {
324            SettingsTab::Symbol => 616.0,
325            SettingsTab::Alerts | SettingsTab::Events => 500.0,
326            _ => TALLEST_TAB_HEIGHT,
327        });
328        let width = if self.config.width > 0.0 {
329            self.config.width
330        } else {
331            752.0
332        };
333        let screen = ctx.content_rect();
334        // The tallest tab sits centred in the window; shorter tabs keep its top edge.
335        let tallest_height = fixed_height.unwrap_or(TALLEST_TAB_HEIGHT);
336        let tallest_height =
337            tallest_height.min((screen.height() - 2.0 * ctx.active_theme().spacing.xxxl).max(0.0));
338        Modal::new("chart_settings", "Chart settings")
339            .width(width)
340            .height(height)
341            .top(screen.center().y - tallest_height / 2.0)
342            .dim_background(false)
343            .show(ctx, &mut is_open, |ui| {
344                ui.add_space(theme.spacing.xxxl);
345                action = self.draw_content(ui, theme);
346            });
347
348        // Only update is_open from egui's Window if btns didn't already close it
349        // (btns set self.is_open = false directly)
350        if self.is_open && !is_open {
351            // Window was closed via egui's mechanism (outside click, etc)
352            self.is_open = false;
353            action = SettingsAction::Cancel;
354        }
355
356        if let SettingsAction::Apply(settings) = &mut action {
357            let basic = &mut settings.chart_basic_styles;
358            for (color, themed) in [
359                (&mut basic.background_color, theme.chart.bg),
360                (&mut basic.background_gradient_top, theme.chart.bg),
361                (&mut basic.background_gradient_bottom, theme.chart.bg_axis),
362            ] {
363                if *color == Color32::PLACEHOLDER {
364                    *color = themed;
365                }
366            }
367        }
368
369        if let Some((name, volume)) = self.sound_preview.take() {
370            return SettingsAction::PreviewSound { name, volume };
371        }
372        action
373    }
374
375    fn draw_content(&mut self, ui: &mut Ui, theme: &Theme) -> SettingsAction {
376        let mut action = SettingsAction::None;
377
378        let content_height = (ui.available_height() - 68.0).max(0.0);
379
380        let sidebar_width = SIDEBAR_WIDTH.min(ui.available_width() * 0.4);
381        let mut active = self.active_tab;
382        let order_flow_available = self.order_flow_layers.iter().any(|available| *available);
383        ui.allocate_ui(Vec2::new(ui.available_width(), content_height), |ui| {
384            SidebarLayout::new(sidebar_width).show(
385                ui,
386                |ui| {
387                    egui::ScrollArea::vertical()
388                        .id_salt("settings_tabs")
389                        .show(ui, |ui| {
390                            for tab in SettingsTab::all() {
391                                if *tab == SettingsTab::OrderFlow && !order_flow_available {
392                                    continue;
393                                }
394                                if SidebarTab::new(tab.name(), active == *tab)
395                                    .icon(tab.icon())
396                                    .show(ui)
397                                {
398                                    active = *tab;
399                                }
400                            }
401                        });
402                },
403                |ui| {
404                    egui::ScrollArea::both()
405                        .id_salt("settings_content")
406                        .auto_shrink([false, false])
407                        .max_height(ui.available_height())
408                        .show(ui, |ui| {
409                            ui.spacing_mut().item_spacing = Vec2::splat(theme.spacing.lg);
410                            match self.active_tab {
411                                SettingsTab::Symbol => self.draw_symbol_tab(ui, theme),
412                                SettingsTab::OrderFlow => self.draw_order_flow_tab(ui, theme),
413                                SettingsTab::StatusLine => self.draw_status_line_tab(ui, theme),
414                                SettingsTab::ScalesAndLines => {
415                                    self.draw_scales_and_lines_tab(ui, theme)
416                                }
417                                SettingsTab::Canvas => self.draw_canvas_tab(ui, theme),
418                                SettingsTab::Trading => self.draw_trading_tab(ui, theme),
419                                SettingsTab::Alerts => self.draw_alerts_tab(ui, theme),
420                                SettingsTab::Events => self.draw_events_tab(ui, theme),
421                            }
422                        });
423                },
424            );
425        });
426        self.active_tab = active;
427
428        let error = self
429            .settings
430            .validate()
431            .err()
432            .map(|error| error.to_string())
433            .or_else(|| {
434                (self.margin_top_str.parse::<f32>().is_err()
435                    || self.margin_bottom_str.parse::<f32>().is_err()
436                    || self.margin_right_str.parse::<u32>().is_err())
437                .then(|| "Enter valid margins".to_owned())
438            });
439        let (ok, cancel) = DialogFooter::new("Ok")
440            .secondary("Cancel")
441            .primary_enabled(error.is_none())
442            .show_with_left(ui, |ui| {
443                if let Some(error) = &error {
444                    crate::ui_kit::status_message::status_message(
445                        ui,
446                        crate::ui_kit::status_message::MessageKind::Error,
447                        error,
448                    );
449                } else if self.hidden.contains(&Control::Templates) {
450                    self.draw_reset_button(ui);
451                } else {
452                    let button = self.draw_template_button(ui, theme);
453                    if button.clicked() {
454                        self.template_menu_open = !self.template_menu_open;
455                    }
456                    if self.template_menu_open {
457                        action = self.draw_template_menu(ui, theme, &button, action.clone());
458                    }
459                }
460            });
461        if ok {
462            action = SettingsAction::Apply(Box::new(self.settings.clone()));
463            self.is_open = false;
464        } else if cancel {
465            action = SettingsAction::Cancel;
466            self.is_open = false;
467        }
468        action
469    }
470
471    fn draw_template_button(&self, ui: &mut Ui, theme: &Theme) -> Response {
472        let template_label = if self.template_name.is_empty() {
473            "Template".to_string()
474        } else {
475            self.template_name.clone()
476        };
477        ui.add_sized(
478            Vec2::new(
479                DESIGN_TOKENS.sizing.settings_dialog.template_button_width,
480                DESIGN_TOKENS.sizing.settings_dialog.button_height,
481            ),
482            egui::Button::new((
483                RichText::new(l10n::text(ui.ctx(), &template_label))
484                    .size(theme.text.title)
485                    .color(theme.ui.text),
486                embedded_icons::CHEVRON_DOWN.as_image_tinted(Vec2::splat(12.0), theme.ui.text),
487            ))
488            .truncate()
489            .fill(theme.ui.panel_bg)
490            .stroke(egui::Stroke::new(
491                DESIGN_TOKENS.stroke.hairline,
492                theme.ui.border_control,
493            ))
494            .corner_radius(theme.radius.md as u8)
495            .min_size(Vec2::new(
496                DESIGN_TOKENS.sizing.settings_dialog.template_button_width,
497                DESIGN_TOKENS.sizing.settings_dialog.button_height,
498            )),
499        )
500        .on_hover_text(l10n::text(ui.ctx(), template_label))
501    }
502
503    fn draw_template_menu(
504        &mut self,
505        ui: &mut Ui,
506        theme: &Theme,
507        template_btn: &Response,
508        action: SettingsAction,
509    ) -> SettingsAction {
510        let menu_id = ui.scope_id().with("template_menu");
511        let menu_rect = Rect::from_min_size(
512            template_btn.rect.left_bottom() + Vec2::new(0.0, theme.spacing.xs),
513            Vec2::new(
514                DESIGN_TOKENS.sizing.settings_dialog.template_menu_width,
515                DESIGN_TOKENS
516                    .sizing
517                    .settings_dialog
518                    .template_menu_max_height,
519            ),
520        );
521
522        let result = egui::Area::new(menu_id)
523            .fixed_pos(menu_rect.min)
524            .order(egui::Order::Foreground)
525            .show(ui.ctx(), |ui| {
526                egui::Frame::popup(ui.style())
527                    .fill(theme.ui.panel_bg)
528                    .corner_radius(theme.radius.md as u8)
529                    .stroke(egui::Stroke::new(
530                        DESIGN_TOKENS.stroke.hairline,
531                        theme.ui.border_control,
532                    ))
533                    .show(ui, |ui| {
534                        ui.set_width(DESIGN_TOKENS.sizing.settings_dialog.template_menu_width);
535                        egui::ScrollArea::vertical()
536                            .max_height(
537                                DESIGN_TOKENS
538                                    .sizing
539                                    .settings_dialog
540                                    .template_menu_max_height,
541                            )
542                            .show(ui, |ui| self.draw_template_menu_items(ui, theme, action))
543                            .inner
544                    })
545                    .inner
546            });
547
548        self.handle_template_menu_close(ui, template_btn, result.response.rect);
549        result.inner
550    }
551
552    fn draw_template_menu_items(
553        &mut self,
554        ui: &mut Ui,
555        theme: &Theme,
556        mut action: SettingsAction,
557    ) -> SettingsAction {
558        action = self.draw_save_as_button(ui, action);
559        action = self.draw_update_current_button(ui, action);
560        ui.separator();
561        action = self.draw_template_list(ui, theme, action);
562        ui.separator();
563        self.draw_reset_button(ui);
564        action
565    }
566
567    fn draw_save_as_button(&mut self, ui: &mut Ui, mut action: SettingsAction) -> SettingsAction {
568        if let Some(name) = &mut self.save_name {
569            let label = ui.label(l10n::text(ui.ctx(), "Template name"));
570            ui.add(egui::TextEdit::singleline(name).desired_width(ui.available_width()))
571                .labelled_by(label.id);
572            let name = name.trim().to_owned();
573            let label = if self.templates.entries().contains_key(&name) {
574                "Replace"
575            } else {
576                "Save"
577            };
578            let save = ui
579                .add_enabled(
580                    self.templates.ready() && !name.is_empty(),
581                    egui::Button::new(l10n::text(ui.ctx(), label)),
582                )
583                .clicked();
584            let cancel = ui.button(l10n::text(ui.ctx(), "Cancel")).clicked();
585            if save {
586                match self.templates.save(&name, self.settings.clone()) {
587                    Ok(()) => {
588                        self.template_name = name.clone();
589                        action = SettingsAction::SaveTemplate(name);
590                        self.save_name = None;
591                    }
592                    Err(error) => self.templates.error = Some(error),
593                }
594            } else if cancel {
595                self.save_name = None;
596            }
597        } else if ui
598            .button(
599                RichText::new(l10n::text(ui.ctx(), "Save as...")).size(ui.active_theme().text.body),
600            )
601            .clicked()
602        {
603            self.save_name = Some(String::new());
604        }
605        if let Some(error) = &self.templates.error {
606            ui.colored_label(ui.visuals().error_fg_color, l10n::text(ui.ctx(), error));
607            if !self.templates.ready()
608                && ui
609                    .button(l10n::text(ui.ctx(), "Retry loading templates"))
610                    .clicked()
611            {
612                self.templates.reload();
613            }
614        }
615
616        action
617    }
618
619    fn draw_update_current_button(
620        &mut self,
621        ui: &mut Ui,
622        mut action: SettingsAction,
623    ) -> SettingsAction {
624        if !self.template_name.is_empty()
625            && ui
626                .add_enabled(
627                    self.templates.ready(),
628                    egui::Button::new(
629                        RichText::new(l10n::text(ui.ctx(), "Update current"))
630                            .size(ui.active_theme().text.body),
631                    ),
632                )
633                .clicked()
634        {
635            match self
636                .templates
637                .save(&self.template_name, self.settings.clone())
638            {
639                Ok(()) => action = SettingsAction::SaveTemplate(self.template_name.clone()),
640                Err(error) => self.templates.error = Some(error),
641            }
642        }
643        action
644    }
645
646    fn draw_template_list(
647        &mut self,
648        ui: &mut Ui,
649        theme: &Theme,
650        mut action: SettingsAction,
651    ) -> SettingsAction {
652        let templates: Vec<String> = self.templates.entries().keys().cloned().collect();
653
654        if templates.is_empty() {
655            ui.label(
656                RichText::new(l10n::text(ui.ctx(), "No saved templates"))
657                    .size(theme.text.small)
658                    .color(theme.ui.text_secondary),
659            );
660        } else {
661            for name in templates {
662                action = self.draw_template_item(ui, theme, name, action);
663            }
664        }
665        action
666    }
667
668    fn draw_template_item(
669        &mut self,
670        ui: &mut Ui,
671        theme: &Theme,
672        name: String,
673        mut action: SettingsAction,
674    ) -> SettingsAction {
675        let is_sel = self.template_name == name;
676        let text = if is_sel {
677            RichText::new(l10n::text(ui.ctx(), &name))
678                .size(theme.text.body)
679                .color(theme.ui.text)
680        } else {
681            RichText::new(l10n::text(ui.ctx(), &name)).size(theme.text.body)
682        };
683
684        if ui
685            .add_enabled(self.templates.ready(), egui::Button::new(text).truncate())
686            .on_hover_text(l10n::text(ui.ctx(), &name))
687            .clicked()
688        {
689            if let Some(template) = self.templates.entries().get(&name) {
690                let kind = self.settings.chart_type;
691                let params = template
692                    .chart_type_params
693                    .clone()
694                    .or_else(|| self.settings.chart_type_params.clone());
695                let source = template.price_source.or(self.settings.price_source);
696                self.settings = template.clone();
697                self.settings.chart_type = kind;
698                self.settings.chart_type_params = params;
699                self.settings.price_source = source;
700                self.template_name = name.clone();
701                self.update_color_pickers_from_settings();
702                action = SettingsAction::LoadTemplate(name.clone());
703            }
704            self.template_menu_open = false;
705        }
706        if ui
707            .add_enabled(
708                self.templates.ready(),
709                egui::Button::new(l10n::format_text(
710                    ui.ctx(),
711                    "Delete {name}",
712                    &[("name", name.to_string())],
713                ))
714                .truncate(),
715            )
716            .clicked()
717        {
718            match self.templates.delete(&name) {
719                Ok(()) => {
720                    if self.template_name == name {
721                        self.template_name.clear();
722                    }
723                }
724                Err(error) => self.templates.error = Some(error),
725            }
726        }
727        action
728    }
729
730    fn draw_reset_button(&mut self, ui: &mut Ui) {
731        if ui
732            .button(
733                RichText::new(l10n::text(ui.ctx(), "Reset to defaults"))
734                    .size(ui.active_theme().text.body),
735            )
736            .clicked()
737        {
738            self.settings = ChartSettingsState {
739                chart_type: self.settings.chart_type,
740                chart_type_params: Some(Default::default()),
741                price_source: Some(Default::default()),
742                ..Default::default()
743            };
744            self.template_name.clear();
745            self.update_color_pickers_from_settings();
746            self.template_menu_open = false;
747        }
748    }
749
750    fn handle_template_menu_close(&mut self, ui: &Ui, template_btn: &Response, menu_rect: Rect) {
751        if ui.input(|i| i.pointer.any_click())
752            && !template_btn.hovered()
753            && let Some(pos) = ui.input(|i| i.pointer.interact_pos())
754            && !menu_rect.contains(pos)
755        {
756            self.template_menu_open = false;
757        }
758    }
759}
760
761#[cfg(test)]
762mod tests {
763    use super::*;
764    use egui::Pos2;
765
766    #[test]
767    fn enabled_sound_controls_offer_a_preview_without_applying_settings() {
768        let ctx = Context::default();
769        let mut settings = ChartSettingsState::default();
770        settings.trading.play_execution_sound = true;
771        settings.trading.execution_sound = "Bell".into();
772        settings.trading.execution_sound_volume = 0.4;
773        let mut dialog = SettingsDialog::new();
774        dialog.execution_sounds = vec!["Chime".into(), "Bell".into()];
775        dialog.open(settings.clone());
776        dialog.active_tab = SettingsTab::Trading;
777        let mut preview = None;
778        let mut action = SettingsAction::None;
779        for step in 0..5 {
780            let events = match (step, preview) {
781                (3, Some(pos)) | (4, Some(pos)) => vec![
782                    egui::Event::PointerMoved(pos),
783                    egui::Event::PointerButton {
784                        pos,
785                        pressed: step == 3,
786                        button: egui::PointerButton::Primary,
787                        modifiers: Default::default(),
788                    },
789                ],
790                _ => vec![],
791            };
792            let output = ctx.run_ui(
793                egui::RawInput {
794                    screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(1440.0, 900.0))),
795                    events,
796                    ..Default::default()
797                },
798                |ui| action = dialog.show(ui.ctx(), &Theme::light()),
799            );
800            preview = output.shapes.iter().find_map(|shape| match &shape.shape {
801                egui::Shape::Text(text) if text.galley.text() == "Test sound" => {
802                    Some(text.pos + text.galley.size() / 2.0)
803                }
804                _ => None,
805            });
806            output.drop_without_applying_deltas();
807            if step >= 2 {
808                assert!(
809                    preview.is_some(),
810                    "an enabled sound needs a local preview control"
811                );
812            }
813        }
814        assert!(matches!(
815            action,
816            SettingsAction::PreviewSound { name, volume } if name == "Bell" && volume == 0.4
817        ));
818        assert!(dialog.is_open);
819        assert_eq!(dialog.settings(), &settings);
820    }
821
822    #[test]
823    fn symbol_controls_follow_the_chart_type_and_retain_its_parameters() {
824        use crate::{
825            Chart,
826            model::{BarData, ChartType, PriceSource},
827        };
828        for (kind, label) in [
829            (ChartType::Heikin, "Body"),
830            (ChartType::Bars, "Body"),
831            (ChartType::Line, "Line width"),
832            (ChartType::Baseline, "Base level"),
833            (ChartType::Renko, "Size method"),
834            (ChartType::Range, "Size method"),
835        ] {
836            let mut chart = Chart::new(BarData::new());
837            chart.set_chart_type(kind);
838            let mut params = chart.chart_type_params().clone();
839            params.styles.line.width = 4.5;
840            params.renko_brick_size = 7.0;
841            chart.set_chart_type_params(params.clone());
842            chart.config_mut().price_source = PriceSource::HL2;
843            let settings = ChartSettingsState::from_chart(&chart);
844            let mut dialog = SettingsDialog::new();
845            dialog.open(settings.clone());
846            let ctx = Context::default();
847            let output = ctx.run_ui(Default::default(), |ui| {
848                dialog.draw_symbol_tab(ui, &Theme::light())
849            });
850            let has_label = output.shapes.iter().any(|shape| {
851                matches!(&shape.shape,
852                egui::Shape::Text(text) if text.galley.text() == label)
853            });
854            let candle_colors = output.shapes.iter().any(|shape| {
855                matches!(&shape.shape,
856                egui::Shape::Text(text) if text.galley.text() == "CANDLES")
857            });
858            output.drop_without_applying_deltas();
859            assert!(has_label, "{kind:?} must offer {label}");
860            assert!(!candle_colors);
861            let mut restored = Chart::new(BarData::new());
862            restored.set_chart_type(kind);
863            settings.apply_to_chart(&mut restored).unwrap();
864            assert_eq!(restored.chart_type_params(), &params);
865            assert_eq!(restored.config().price_source, PriceSource::HL2);
866        }
867    }
868
869    #[test]
870    fn offered_time_zones_and_session_presets_are_available() {
871        use crate::config::SessionConfig;
872        let mut zones = SettingsDialog::new().timezones;
873        zones.extend(
874            [
875                SessionConfig::default(),
876                SessionConfig::nyse(),
877                SessionConfig::lse(),
878                SessionConfig::tse(),
879                SessionConfig::crypto_24_7(),
880                SessionConfig::forex(),
881            ]
882            .map(|session| session.timezone),
883        );
884        for zone in zones {
885            assert!(zone.parse::<crate::model::TimezoneId>().is_ok(), "{zone}");
886        }
887    }
888
889    #[test]
890    fn save_as_prompts_for_a_name_without_saving_automatically() {
891        let ctx = Context::default();
892        let mut dialog = SettingsDialog::new();
893        let mut frame = |events| {
894            let mut action = SettingsAction::None;
895            let output = ctx.run_ui(
896                egui::RawInput {
897                    events,
898                    ..Default::default()
899                },
900                |ui| {
901                    action = dialog.draw_save_as_button(ui, SettingsAction::None);
902                },
903            );
904            let save_as = output.shapes.iter().find_map(|shape| match &shape.shape {
905                egui::Shape::Text(text) if text.galley.text() == "Save as..." => {
906                    Some(text.pos + text.galley.size() / 2.0)
907                }
908                _ => None,
909            });
910            let prompt = output.shapes.iter().any(|shape| matches!(&shape.shape, egui::Shape::Text(text) if text.galley.text() == "Template name"));
911            output.drop_without_applying_deltas();
912            assert!(
913                matches!(action, SettingsAction::None),
914                "opening the name form must not save a template"
915            );
916            (save_as, prompt)
917        };
918        let pos = frame(vec![]).0.unwrap();
919        frame(vec![egui::Event::PointerMoved(pos)]);
920        for pressed in [true, false] {
921            frame(vec![egui::Event::PointerButton {
922                pos,
923                pressed,
924                button: egui::PointerButton::Primary,
925                modifiers: Default::default(),
926            }]);
927        }
928        assert!(frame(vec![]).1);
929    }
930
931    #[test]
932    fn grid_colour_pickers_follow_the_visible_directions() {
933        use crate::widget::settings::GridLinesMode;
934        for theme in [Theme::light(), Theme::dark()] {
935            for (mode, horizontal, vertical) in [
936                (GridLinesMode::None, false, false),
937                (GridLinesMode::Horizontal, true, false),
938                (GridLinesMode::Vertical, false, true),
939                (GridLinesMode::Both, true, true),
940            ] {
941                let ctx = Context::default();
942                crate::theme::apply_to_egui(&ctx, &theme);
943                let mut settings = ChartSettingsState::default();
944                settings.grid_lines.mode = mode;
945                settings.grid_lines.horizontal_color = Color32::from_rgb(151, 32, 19);
946                settings.grid_lines.vertical_color = Color32::from_rgb(29, 43, 157);
947                let mut dialog = SettingsDialog::new();
948                dialog.open(settings.clone());
949                let output = ctx.run_ui(Default::default(), |ui| {
950                    dialog.draw_canvas_tab(ui, &theme);
951                });
952                let shown = [settings.grid_lines.horizontal_color, settings.grid_lines.vertical_color].map(|colour| output.shapes.iter().any(|shape| matches!(&shape.shape, egui::Shape::Rect(rect) if rect.fill == colour)));
953                output.drop_without_applying_deltas();
954                assert_eq!(shown, [horizontal, vertical], "{mode:?}");
955            }
956        }
957    }
958
959    #[test]
960    fn settings_tracks_theme_without_changing_defaults_until_apply() {
961        for (width, apply) in [
962            (1440.0, false),
963            (480.0, false),
964            (1440.0, true),
965            (480.0, true),
966        ] {
967            for configured in [Color32::PLACEHOLDER, Color32::from_rgb(80, 90, 100)] {
968                let ctx = Context::default();
969                let mut dialog = SettingsDialog::new();
970                let mut settings = ChartSettingsState::default();
971                settings.chart_basic_styles.background_color = configured;
972                settings.margins.top_percent = 12.5;
973                settings.margins.bottom_percent = 7.5;
974
975                dialog.open(settings.clone());
976                assert_eq!(dialog.margin_top_str, "12.5");
977                assert_eq!(dialog.margin_bottom_str, "7.5");
978                let mut frame = |theme: &Theme, events| {
979                    crate::theme::apply_to_egui(&ctx, theme);
980                    let mut action = SettingsAction::None;
981                    let output = ctx.run_ui(
982                        egui::RawInput {
983                            screen_rect: Some(Rect::from_min_size(
984                                Pos2::ZERO,
985                                Vec2::new(width, 900.0),
986                            )),
987                            events,
988                            ..Default::default()
989                        },
990                        |ui| {
991                            action = dialog.show(ui.ctx(), theme);
992                        },
993                    );
994                    let symbol = output.shapes.iter().any(|shape| matches!(&shape.shape, egui::Shape::Text(text) if text.galley.text() == "Body"));
995                    let ok = output.shapes.iter().find_map(|shape| match &shape.shape {
996                        egui::Shape::Text(text) if text.galley.text() == "Ok" => {
997                            Some(text.pos + text.galley.size() / 2.0)
998                        }
999                        _ => None,
1000                    });
1001                    output.drop_without_applying_deltas();
1002                    assert_eq!(dialog.settings, settings);
1003                    assert_eq!(
1004                        dialog.color_picker_states.background.sel_color,
1005                        if configured == Color32::PLACEHOLDER {
1006                            theme.chart.bg
1007                        } else {
1008                            configured
1009                        }
1010                    );
1011                    assert_eq!(
1012                        dialog.color_picker_states.background_gradient_top.sel_color,
1013                        theme.chart.bg
1014                    );
1015                    assert_eq!(
1016                        dialog
1017                            .color_picker_states
1018                            .background_gradient_bottom
1019                            .sel_color,
1020                        theme.chart.bg_axis
1021                    );
1022                    (action, symbol, ok)
1023                };
1024                for theme in [Theme::light(), Theme::dark(), Theme::light()] {
1025                    frame(&theme, vec![]);
1026                    assert!(
1027                        frame(&theme, vec![]).1,
1028                        "settings starts with the symbol controls"
1029                    );
1030                }
1031                let action = if apply {
1032                    let pos = frame(&Theme::light(), vec![]).2.unwrap();
1033                    let button = |pressed| egui::Event::PointerButton {
1034                        pos,
1035                        button: egui::PointerButton::Primary,
1036                        pressed,
1037                        modifiers: Default::default(),
1038                    };
1039                    frame(&Theme::light(), vec![egui::Event::PointerMoved(pos)]);
1040                    frame(&Theme::light(), vec![button(true)]);
1041                    frame(&Theme::light(), vec![button(false)]).0
1042                } else {
1043                    frame(
1044                        &Theme::light(),
1045                        vec![egui::Event::Key {
1046                            key: egui::Key::Escape,
1047                            physical_key: None,
1048                            pressed: true,
1049                            repeat: false,
1050                            modifiers: Default::default(),
1051                        }],
1052                    )
1053                    .0
1054                };
1055                if apply {
1056                    let SettingsAction::Apply(saved) = action else {
1057                        panic!("expected Apply");
1058                    };
1059                    let expected = if configured == Color32::PLACEHOLDER {
1060                        Theme::light().chart.bg
1061                    } else {
1062                        configured
1063                    };
1064                    assert_eq!(saved.chart_basic_styles.background_color, expected);
1065                    assert_eq!(
1066                        saved.chart_basic_styles.background_gradient_top,
1067                        Theme::light().chart.bg
1068                    );
1069                    assert_eq!(
1070                        saved.chart_basic_styles.background_gradient_bottom,
1071                        Theme::light().chart.bg_axis
1072                    );
1073                } else {
1074                    assert!(matches!(action, SettingsAction::Cancel));
1075                }
1076                assert!(!dialog.is_open);
1077                assert_eq!(dialog.settings, settings);
1078            }
1079        }
1080    }
1081    #[test]
1082    fn scale_controls_show_small_ratios_and_keep_modes_exclusive() {
1083        let ctx = Context::default();
1084        let mut dialog = SettingsDialog::new();
1085        let theme = Theme::light();
1086        for (label, expected, ratio) in [
1087            ("Log scale", (true, false), 0.005),
1088            ("Percentage scale", (false, true), 0.00000001),
1089            ("Log scale", (true, false), 1.0),
1090        ] {
1091            dialog.settings.scales_and_lines.price_to_bar_ratio = ratio;
1092            dialog.settings.scales_and_lines.lock_price_to_bar_ratio = true;
1093            let mut frame =
1094                |events| {
1095                    let output = ctx.run_ui(
1096                        egui::RawInput {
1097                            events,
1098                            ..Default::default()
1099                        },
1100                        |ui| {
1101                            dialog.draw_scales_and_lines_tab(ui, &theme);
1102                        },
1103                    );
1104                    let ratio_shown = output.shapes.iter().any(|shape| matches!(&shape.shape,
1105                        egui::Shape::Text(text) if text.galley.text().parse::<f64>() == Ok(ratio)
1106                    ));
1107                    let pos = output
1108                        .shapes
1109                        .iter()
1110                        .find_map(|shape| match &shape.shape {
1111                            egui::Shape::Text(text) if text.galley.text() == label => {
1112                                Some(text.pos + text.galley.size() / 2.0)
1113                            }
1114                            _ => None,
1115                        })
1116                        .unwrap();
1117                    output.drop_without_applying_deltas();
1118                    assert!(ratio_shown, "entered ratio {ratio} must remain legible");
1119                    pos
1120                };
1121            let pos = frame(vec![]);
1122            frame(vec![egui::Event::PointerMoved(pos)]);
1123            for pressed in [true, false] {
1124                frame(vec![egui::Event::PointerButton {
1125                    pos,
1126                    pressed,
1127                    button: egui::PointerButton::Primary,
1128                    modifiers: Default::default(),
1129                }]);
1130            }
1131            assert_eq!(
1132                (
1133                    dialog.settings.scales_and_lines.log_scale,
1134                    dialog.settings.scales_and_lines.percentage_scale
1135                ),
1136                expected
1137            );
1138        }
1139    }
1140
1141    #[test]
1142    fn order_flow_controls_follow_declared_services_and_tick_size() {
1143        use crate::data::FeedCapabilities;
1144        use crate::model::{FeedService::*, Instrument};
1145        let feed = FeedCapabilities::new().with_services([
1146            Trades,
1147            LiveTrades,
1148            Depth,
1149            DepthHistory,
1150            OrderDepth,
1151        ]);
1152        for (services, tick, expected) in [
1153            (None, Some(0.25), [true, true, true, true]),
1154            (
1155                Some(vec![LiveTrades]),
1156                Some(0.25),
1157                [false, false, false, true],
1158            ),
1159            (Some(vec![Trades]), Some(0.25), [false, false, false, true]),
1160            (Some(vec![Depth]), Some(0.25), [true, true, false, false]),
1161            (
1162                Some(vec![DepthHistory]),
1163                Some(0.25),
1164                [true, false, false, false],
1165            ),
1166            (
1167                Some(vec![Depth, OrderDepth]),
1168                Some(0.25),
1169                [true, true, true, false],
1170            ),
1171            (Some(vec![OrderDepth]), Some(0.25), [false; 4]),
1172            (Some(vec![]), Some(0.25), [false; 4]),
1173            (Some(vec![Depth]), None, [false; 4]),
1174            (Some(vec![Depth]), Some(0.0), [false; 4]),
1175            (Some(vec![Depth]), Some(f64::NAN), [false; 4]),
1176        ] {
1177            let ctx = Context::default();
1178            let theme = Theme::light();
1179            crate::theme::apply_to_egui(&ctx, &theme);
1180            let mut dialog = SettingsDialog::new();
1181            dialog.open(ChartSettingsState::default());
1182            let mut instrument = Instrument::new("A");
1183            instrument.services = services;
1184            instrument.tick_size = tick;
1185            dialog.set_feed_capabilities(Some(&feed), Some(&instrument));
1186            assert_eq!(dialog.order_flow_layers, expected);
1187            for pass in 0..3 {
1188                let output = ctx.run_ui(
1189                    egui::RawInput {
1190                        screen_rect: Some(Rect::from_min_size(
1191                            Pos2::ZERO,
1192                            Vec2::new(1440.0, 900.0),
1193                        )),
1194                        ..Default::default()
1195                    },
1196                    |ui| {
1197                        dialog.show(ui.ctx(), &theme);
1198                    },
1199                );
1200                let tab = output.shapes.iter().any(|shape| {
1201                    matches!(&shape.shape,
1202                    egui::Shape::Text(text) if text.galley.text() == "Order flow")
1203                });
1204                output.drop_without_applying_deltas();
1205                if pass == 2 {
1206                    assert_eq!(tab, expected.iter().any(|available| *available));
1207                }
1208            }
1209            // Existing overlay configs define fallback behaviour for these values.
1210            dialog.settings.order_flow.heatmap.full_intensity_size = Some(0.0);
1211            dialog.settings.order_flow.volume_bubbles.grouping =
1212                crate::chart::overlays::BubbleGrouping::Executions(0);
1213            let before = dialog.settings.clone();
1214            let output = ctx.run_ui(Default::default(), |ui| {
1215                dialog.draw_order_flow_tab(ui, &theme);
1216            });
1217            for (label, shown) in [
1218                ("Market depth heatmap", expected[0]),
1219                ("Order book", expected[1]),
1220                ("Volume bubbles", expected[3]),
1221            ] {
1222                assert_eq!(
1223                    output.shapes.iter().any(|shape| matches!(&shape.shape,
1224                    egui::Shape::Text(text) if text.galley.text() == label)),
1225                    shown,
1226                    "{label}"
1227                );
1228            }
1229            output.drop_without_applying_deltas();
1230            assert_eq!(
1231                dialog.settings, before,
1232                "viewing controls must preserve host preferences"
1233            );
1234            dialog.active_tab = SettingsTab::OrderFlow;
1235            dialog.set_feed_capabilities(Some(&feed), None);
1236            assert_eq!(dialog.active_tab, SettingsTab::Symbol);
1237        }
1238        let mut dialog = SettingsDialog::new();
1239        assert_eq!(dialog.order_flow_layers, [true; 4]);
1240        let instrument = Instrument::new("A")
1241            .with_tick_size(0.25)
1242            .with_services([Depth]);
1243        dialog.set_feed_capabilities(None, Some(&instrument));
1244        assert_eq!(dialog.order_flow_layers, [true, true, false, false]);
1245        dialog.set_feed_capabilities(
1246            Some(&FeedCapabilities::new().with_services([])),
1247            Some(&instrument),
1248        );
1249        assert_eq!(dialog.order_flow_layers, [false; 4]);
1250        dialog.set_feed_capabilities(None, Some(&Instrument::new("A").with_tick_size(0.25)));
1251        assert_eq!(dialog.order_flow_layers, [false; 4]);
1252    }
1253
1254    #[test]
1255    fn order_flow_heatmap_switch_applies_without_enabling_other_layers() {
1256        let ctx = Context::default();
1257        let theme = Theme::light();
1258        crate::theme::apply_to_egui(&ctx, &theme);
1259        let mut dialog = SettingsDialog::new();
1260        let mut settings = ChartSettingsState::default();
1261        settings.order_flow.heatmap.lower_cutoff = 0.00000001;
1262        settings.order_flow.heatmap.full_intensity_size = Some(0.00000002);
1263        dialog.open(settings);
1264        dialog.active_tab = SettingsTab::OrderFlow;
1265        let mut frame = |events| {
1266            let mut action = SettingsAction::None;
1267            let output = ctx.run_ui(
1268                egui::RawInput {
1269                    screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(1440.0, 900.0))),
1270                    events,
1271                    ..Default::default()
1272                },
1273                |ui| {
1274                    action = dialog.show(ui.ctx(), &theme);
1275                },
1276            );
1277            let positions = ["Market depth heatmap", "Ok"].map(|label| {
1278                output.shapes.iter().find_map(|shape| match &shape.shape {
1279                    egui::Shape::Text(text) if text.galley.text() == label => {
1280                        Some(text.pos + text.galley.size() / 2.0)
1281                    }
1282                    _ => None,
1283                })
1284            });
1285            let sizes_visible = [0.00000001, 0.00000002].map(|value| {
1286                output.shapes.iter().any(|shape| {
1287                    matches!(&shape.shape,
1288                egui::Shape::Text(text) if text.galley.text().parse::<f64>() == Ok(value))
1289                })
1290            });
1291            output.drop_without_applying_deltas();
1292            (action, positions, sizes_visible)
1293        };
1294        frame(vec![]);
1295        frame(vec![]);
1296        for index in [0, 1] {
1297            let pos = frame(vec![]).1[index].unwrap();
1298            frame(vec![egui::Event::PointerMoved(pos)]);
1299            frame(vec![egui::Event::PointerButton {
1300                pos,
1301                pressed: true,
1302                button: egui::PointerButton::Primary,
1303                modifiers: Default::default(),
1304            }]);
1305            let (action, _, _) = frame(vec![egui::Event::PointerButton {
1306                pos,
1307                pressed: false,
1308                button: egui::PointerButton::Primary,
1309                modifiers: Default::default(),
1310            }]);
1311            if index == 0 {
1312                assert_eq!(
1313                    frame(vec![]).2,
1314                    [true; 2],
1315                    "small sizes must remain legible"
1316                );
1317            }
1318            if index == 1 {
1319                let SettingsAction::Apply(saved) = action else {
1320                    panic!("expected Apply")
1321                };
1322                assert!(saved.order_flow.show_heatmap);
1323                assert!(!saved.order_flow.show_volume_bubbles);
1324                assert!(!saved.order_flow.show_order_book);
1325                dialog.open(*saved);
1326                assert!(dialog.settings().order_flow.show_heatmap);
1327                assert!(!dialog.settings().order_flow.show_volume_bubbles);
1328                break;
1329            }
1330        }
1331    }
1332
1333    #[test]
1334    fn changing_settings_tabs_keeps_the_top_edge_and_fits_the_window() {
1335        let mut scaled = Theme::light();
1336        scaled.text.small = 17.0;
1337        scaled.spacing.lg = 12.0;
1338        scaled.spacing.xxxl = 32.0;
1339        for theme in [Theme::light(), Theme::dark(), scaled] {
1340            let ctx = Context::default();
1341            crate::theme::apply_to_egui(&ctx, &theme);
1342            let mut dialog = SettingsDialog::new();
1343            dialog.open(ChartSettingsState::default());
1344            dialog.template_name = "Long template name ".repeat(20);
1345            for size in [
1346                Vec2::new(1440.0, 900.0),
1347                Vec2::new(600.0, 400.0),
1348                Vec2::new(320.0, 450.0),
1349                Vec2::new(1440.0, 900.0),
1350            ] {
1351                let mut tops = Vec::new();
1352                for tab in SettingsTab::all().iter().chain(&[
1353                    SettingsTab::Events,
1354                    SettingsTab::Symbol,
1355                    SettingsTab::Events,
1356                ]) {
1357                    dialog.active_tab = *tab;
1358                    for pass in 0..3 {
1359                        let output = ctx.run_ui(
1360                            egui::RawInput {
1361                                screen_rect: Some(Rect::from_min_size(Pos2::ZERO, size)),
1362                                ..Default::default()
1363                            },
1364                            |ui| {
1365                                dialog.show(ui.ctx(), &theme);
1366                            },
1367                        );
1368                        let sidebar = output.shapes.iter().find_map(|shape| match &shape.shape {
1369                            egui::Shape::Rect(row)
1370                                if row.rect.height() == 40.0
1371                                    && row.rect.width() <= SIDEBAR_WIDTH =>
1372                            {
1373                                Some(row.rect)
1374                            }
1375                            _ => None,
1376                        });
1377                        let labels_fit = sidebar.is_none_or(|row| {
1378                            output.shapes.iter().all(|shape| match &shape.shape {
1379                                egui::Shape::Text(text)
1380                                    if SettingsTab::all()
1381                                        .iter()
1382                                        .any(|tab| tab.name() == text.galley.text()) =>
1383                                {
1384                                    text.pos.x + text.galley.size().x <= row.right() + 0.5
1385                                }
1386                                _ => true,
1387                            })
1388                        });
1389                        let heading = output.shapes.iter().find_map(|shape| match &shape.shape {
1390                            egui::Shape::Text(text) if text.galley.text() == "CANDLES" => {
1391                                Some(text.galley.job.sections[0].format.font_id.size)
1392                            }
1393                            _ => None,
1394                        });
1395                        output.drop_without_applying_deltas();
1396                        if pass == 2 {
1397                            assert!(
1398                                labels_fit,
1399                                "tab labels must stay in the sidebar at {size:?}"
1400                            );
1401                        }
1402                        if *tab == SettingsTab::Symbol && pass == 2 {
1403                            assert_eq!(heading, Some(theme.text.small));
1404                        }
1405                    }
1406                    let rect = ctx
1407                        .memory(|memory| memory.area_rect(egui::Id::unique("chart_settings")))
1408                        .unwrap();
1409                    assert!(
1410                        Rect::from_min_size(Pos2::ZERO, size).contains_rect(rect),
1411                        "{tab:?}: {rect:?} in {size:?}"
1412                    );
1413                    tops.push(rect.top());
1414                }
1415                assert!(
1416                    tops.iter().all(|top| (top - tops[0]).abs() < 1.0),
1417                    "{tops:?}"
1418                );
1419            }
1420        }
1421    }
1422}