1use 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
27const SIDEBAR_WIDTH: f32 = 206.0;
33
34const TALLEST_TAB_HEIGHT: f32 = 840.0;
36
37pub struct SettingsDialog {
42 pub is_open: bool,
44 active_tab: SettingsTab,
45 settings: ChartSettingsState,
46 template_name: String,
48 template_menu_open: bool,
50 pub templates: TemplateStore<ChartSettingsState>,
52 save_name: Option<String>,
53 timezones: Vec<String>,
54 config: SettingsDialogConfig,
56 color_picker_states: ColorPickerStates,
57 margin_top_str: String,
58 margin_bottom_str: String,
59 margin_right_str: String,
60 hidden: HashSet<Control>,
62 pub execution_sounds: Vec<String>,
67 sound_preview: Option<(String, f32)>,
68 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 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 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 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 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 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 pub fn close(&mut self) {
279 self.is_open = false;
280 }
281
282 pub fn settings(&self) -> &ChartSettingsState {
284 &self.settings
285 }
286
287 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 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 if self.is_open && !is_open {
351 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(), ¶ms);
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 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}