Skip to main content

egui_charts/widget/settings/
apply.rs

1//! Apply and read the chart settings exposed by the shared dialog.
2
3use super::{
4    BackgroundType, ButtonVisibility, ChartSettingsState, GridLinesMode, PrecisionMode,
5    WatermarkMode,
6};
7use crate::{
8    Chart,
9    config::{BackgroundStyle, ChartConfig, NavigationButtons},
10    model::CandlesStyle,
11    scales::{PriceScaleMode, TimezoneMode},
12    theme::Theme,
13};
14use egui::Color32;
15
16/// The axis label size the dialog shows: the configured one, else the default theme's.
17fn axis_font_size(config: &ChartConfig) -> u8 {
18    config
19        .axis_font_size
20        .unwrap_or(Theme::default().text.label)
21        .round() as u8
22}
23
24/// The axis line colour the dialog shows; transparent when the price axis draws no line.
25fn axis_line_color(config: &ChartConfig) -> Color32 {
26    if config.price_axis.border_visible {
27        config
28            .price_axis
29            .border_color
30            .unwrap_or(Theme::default().ui.border)
31    } else {
32        Color32::TRANSPARENT
33    }
34}
35
36fn button_visibility(buttons: NavigationButtons) -> ButtonVisibility {
37    match buttons {
38        NavigationButtons::Always => ButtonVisibility::AlwaysVisible,
39        NavigationButtons::OnHover => ButtonVisibility::VisibleOnMouseOver,
40        NavigationButtons::Never => ButtonVisibility::Hidden,
41    }
42}
43
44fn navigation_buttons(visibility: ButtonVisibility) -> NavigationButtons {
45    match visibility {
46        ButtonVisibility::AlwaysVisible => NavigationButtons::Always,
47        ButtonVisibility::VisibleOnMouseOver => NavigationButtons::OnHover,
48        ButtonVisibility::Hidden => NavigationButtons::Never,
49    }
50}
51
52/// Why [`ChartSettingsState::apply_to_chart`] left the chart unchanged. Its text is the one
53/// the settings dialog shows.
54#[derive(Clone, Debug, PartialEq)]
55#[non_exhaustive]
56pub enum ChartSettingsError {
57    /// `timezone` names no time zone this build knows.
58    Timezone(crate::model::ParseError),
59    /// The margins are not finite, are negative or leave no room for prices.
60    Margins {
61        /// The top margin in percent.
62        top: f32,
63        /// The bottom margin in percent.
64        bottom: f32,
65    },
66    /// The crosshair line width is not finite or not above zero.
67    CrosshairWidth(f32),
68    /// The axis font size is zero.
69    FontSize(u8),
70    /// The locked price-to-bar ratio is not finite or not above zero.
71    PriceToBarRatio(f64),
72    /// The trading execution sound volume is outside 0 to 1.
73    ExecutionSoundVolume(f32),
74    /// The alert sound volume is outside 0 to 1.
75    AlertSoundVolume(f32),
76    /// The chart type parameters do not fit the chart type.
77    ChartTypeParams {
78        /// The chart type they were checked against.
79        chart_type: crate::model::ChartType,
80        /// Why they were refused.
81        reason: &'static str,
82    },
83}
84
85impl std::fmt::Display for ChartSettingsError {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        f.write_str(match self {
88            Self::Timezone(_) => "Invalid timezone",
89            Self::Margins { .. } => "Margins must leave room for prices",
90            Self::CrosshairWidth(_) => "Crosshair width must be above zero",
91            Self::FontSize(_) => "Font sizes must be between 1 and 256",
92            Self::PriceToBarRatio(_) => "Price-to-bar ratio must be positive and finite",
93            Self::ExecutionSoundVolume(_) | Self::AlertSoundVolume(_) => {
94                "Sound volume must be between zero and one"
95            }
96            Self::ChartTypeParams { reason, .. } => reason,
97        })
98    }
99}
100impl std::error::Error for ChartSettingsError {
101    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
102        match self {
103            Self::Timezone(error) => Some(error),
104            _ => None,
105        }
106    }
107}
108
109impl ChartSettingsState {
110    /// Read the chart's current display settings. Host-managed trading and alert groups
111    /// retain defaults; hosts preserve their current values when refreshing chart settings.
112    pub fn from_chart(chart: &Chart) -> Self {
113        let config = chart.config();
114        let mut settings = Self {
115            chart_type: chart.chart_type(),
116            chart_type_params: Some(chart.chart_type_params().clone()),
117            price_source: Some(chart.config().price_source),
118            events: chart.event_visibility().clone(),
119            order_flow: super::OrderFlowSettings {
120                show_heatmap: chart
121                    .feed_link
122                    .as_ref()
123                    .is_some_and(|link| link.heatmap.is_some()),
124                heatmap: chart.liquidity_heatmap_config(),
125                show_order_book: chart
126                    .feed_link
127                    .as_ref()
128                    .is_some_and(|link| link.book_visible.unwrap_or(link.heatmap.is_some())),
129                order_book: chart.order_book_view(),
130                show_volume_bubbles: chart
131                    .feed_link
132                    .as_ref()
133                    .is_some_and(|link| link.bubbles.is_some()),
134                volume_bubbles: chart
135                    .feed_link
136                    .as_ref()
137                    .map(|link| link.bubbles_config.clone())
138                    .unwrap_or_default(),
139            },
140            show_price_targets: chart.price_targets_visible(),
141            ..Self::default()
142        };
143        settings.candle_colors.body_up = config.bullish_color;
144        settings.candle_colors.body_down = config.bearish_color;
145        settings.candle_colors.border_up =
146            config.bullish_border_color.unwrap_or(config.bullish_color);
147        settings.candle_colors.border_down =
148            config.bearish_border_color.unwrap_or(config.bearish_color);
149        settings.candle_colors.wick_up = config
150            .bullish_wick_color
151            .unwrap_or(settings.candle_colors.border_up);
152        settings.candle_colors.wick_down = config
153            .bearish_wick_color
154            .unwrap_or(settings.candle_colors.border_down);
155        let candles = &chart.chart_type_params().styles.candles;
156        settings.candle_colors.show_body = candles.body_visible;
157        settings.candle_colors.show_border = config.candle_border_width > 0.0;
158        settings.candle_colors.show_wick = candles.wick_visible;
159        settings.candle_colors.color_by_previous_close = candles.color_by_previous_close;
160        settings.chart_basic_styles.background_color = config.background_color;
161        settings.grid_lines.horizontal_color = config.grid_color;
162        settings.grid_lines.vertical_color =
163            config.vertical_grid_color.unwrap_or(config.grid_color);
164        settings.status_line.show_logo = config.show_legend_badge;
165        settings.status_line.show_market_status = config.show_market_status;
166        settings.status_line.show_symbol = config.show_symbol_labels;
167        settings.status_line.title_as_description = config.title_as_description;
168        settings.status_line.show_quote_boxes = config.show_quote_boxes;
169        settings.status_line.show_ohlc = config.show_ohlc_info;
170        settings.status_line.show_change = config.show_ohlc_change;
171        settings.status_line.show_change_percent = config.show_ohlc_change_percent;
172        settings.status_line.show_volume = config.show_legend_volume;
173        settings.status_line.show_indicator_titles = config.show_indicator_labels;
174        settings.status_line.show_indicator_inputs = config.show_indicator_inputs;
175        settings.status_line.show_indicator_values = config.show_indicator_last_val;
176        settings.status_line.show_last_day_change = config.show_last_day_change;
177        settings.status_line.indicator_background_opacity =
178            (config.indicator_backdrop_opacity.clamp(0.0, 1.0) * 100.0).round() as u8;
179        settings.scales_and_lines.price_scale_left =
180            !config.show_right_axis && config.show_left_axis;
181        settings.scales_and_lines.show_currency = config.show_currency;
182        settings.scales_and_lines.show_unit = config.show_unit;
183        settings.scales_and_lines.show_scale_mode_buttons = config.show_scale_mode_buttons;
184        settings.scales_and_lines.show_plus_button = config.show_plus_button;
185        settings.scales_and_lines.allow_label_overlap = config.price_axis.allow_label_overlap;
186        settings.scales_and_lines.show_study_values = config.price_axis.show_study_values;
187        settings.scales_and_lines.study_value_color = config.price_axis.study_value_color;
188        settings.scales_and_lines.show_symbol_name = config.price_axis.show_symbol_name;
189        settings.scales_and_lines.show_study_names = config.price_axis.show_study_names;
190        settings.scales_and_lines.show_extended_session_price = config.show_extended_session_price;
191        settings.scales_and_lines.extended_session_price_color =
192            config.extended_session_price_color;
193        settings.scales_and_lines.symbol_price_color = chart
194            .series_style(crate::widget::SeriesId::MAIN)
195            .and_then(|style| style.price_line_color);
196        settings.scales_and_lines.lock_price_to_bar_ratio =
197            chart.state().price_scale().bar_ratio_locked();
198        settings.scales_and_lines.price_to_bar_ratio =
199            chart.state().price_scale().bar_ratio().unwrap_or(1.0);
200        settings.scales_and_lines.show_last_price_label = config.show_symbol_last_val;
201        settings.scales_and_lines.show_price_line = config.show_price_line;
202        settings.scales_and_lines.show_prev_close_label = config.show_symbol_prev_close;
203        settings.scales_and_lines.show_prev_close = config.show_prev_close_line;
204        settings.scales_and_lines.show_countdown = config.show_countdown;
205        settings.scales_and_lines.show_bid_ask = config.show_bid_ask;
206        settings.scales_and_lines.show_high_low = config.show_high_low;
207        settings.margins.top_percent =
208            (chart.state().price_scale().options().scale_margins.top * 100.0) as f32;
209        settings.margins.bottom_percent =
210            (chart.state().price_scale().options().scale_margins.bottom * 100.0) as f32;
211        settings.margins.right_bars = chart.chart_options().time_scale.right_offset.max(0.0) as u32;
212        let scale = chart.state().price_scale();
213        settings.scales_and_lines.auto_scale = !scale.is_manual();
214        settings.scales_and_lines.log_scale = scale.options().mode == PriceScaleMode::Logarithmic;
215        settings.scales_and_lines.percentage_scale =
216            scale.options().mode == PriceScaleMode::Percentage;
217        settings.scales_and_lines.invert_scale = scale.options().invert_scale;
218
219        settings.timezone = match &chart.chart_options().time_scale.timezone {
220            TimezoneMode::Utc => "UTC".into(),
221            TimezoneMode::Local => "Local".into(),
222            TimezoneMode::Timezone(zone) => zone.to_string(),
223            TimezoneMode::Exchange(zone) => format!("Exchange:{zone}"),
224        };
225        settings.grid_lines.mode = match (
226            config.show_grid && config.show_horizontal_grid,
227            config.show_grid && config.show_vertical_grid,
228        ) {
229            (true, true) => GridLinesMode::Both,
230            (true, false) => GridLinesMode::Horizontal,
231            (false, true) => GridLinesMode::Vertical,
232            _ => GridLinesMode::None,
233        };
234        settings.btns.navigation = button_visibility(config.navigation_buttons);
235        let (kind, first, second) = match config.background_style {
236            BackgroundStyle::Solid => (
237                BackgroundType::Solid,
238                config.background_color,
239                config.background_color,
240            ),
241            BackgroundStyle::VerticalGradient {
242                top_color,
243                bottom_color,
244            } => (BackgroundType::VerticalGradient, top_color, bottom_color),
245            BackgroundStyle::HorizontalGradient {
246                left_color,
247                right_color,
248            } => (BackgroundType::HorizontalGradient, left_color, right_color),
249        };
250        settings.chart_basic_styles.background_type = kind;
251        settings.chart_basic_styles.background_gradient_top = first;
252        settings.chart_basic_styles.background_gradient_bottom = second;
253        let crosshair = &chart.chart_options().crosshair;
254        settings.crosshair.color = crosshair.horz_line_color;
255        settings.crosshair.line_width = crosshair.horz_line_width;
256        settings.crosshair.line_style = crosshair.horz_line_style;
257        let decimals = chart
258            .state()
259            .price_scale()
260            .map(Default::default(), 0.0, 1.0)
261            .price_precision();
262        settings.precision = PrecisionMode::all()
263            .iter()
264            .copied()
265            .find(|mode| mode.decimals() == decimals)
266            .unwrap_or_default();
267        settings.watermark.mode = if !config.show_watermark {
268            WatermarkMode::ReplayMode
269        } else if config.watermark_text.is_some() {
270            WatermarkMode::Custom
271        } else if config.watermark_description {
272            WatermarkMode::SymbolAndDescription
273        } else {
274            WatermarkMode::Symbol
275        };
276        settings.watermark.color = config.watermark_color;
277        settings.scales_appearance.text_color = config.text_color;
278        settings.scales_appearance.font_size = axis_font_size(config);
279        settings.scales_appearance.lines_color = axis_line_color(config);
280        settings.btns.pane = button_visibility(config.pane_buttons);
281
282        settings
283    }
284
285    pub(crate) fn validate(&self) -> Result<TimezoneMode, ChartSettingsError> {
286        let timezone = match self.timezone.as_str() {
287            "UTC" => TimezoneMode::Utc,
288            "Local" => TimezoneMode::Local,
289            name => {
290                if let Some(name) = name.strip_prefix("Exchange:") {
291                    TimezoneMode::Exchange(name.parse().map_err(ChartSettingsError::Timezone)?)
292                } else {
293                    TimezoneMode::Timezone(name.parse().map_err(ChartSettingsError::Timezone)?)
294                }
295            }
296        };
297        let top = self.margins.top_percent;
298        let bottom = self.margins.bottom_percent;
299        if !top.is_finite()
300            || !bottom.is_finite()
301            || top < 0.0
302            || bottom < 0.0
303            || top + bottom >= 100.0
304        {
305            return Err(ChartSettingsError::Margins { top, bottom });
306        }
307        if !self.crosshair.line_width.is_finite() || self.crosshair.line_width <= 0.0 {
308            return Err(ChartSettingsError::CrosshairWidth(
309                self.crosshair.line_width,
310            ));
311        }
312        if self.scales_appearance.font_size == 0 {
313            return Err(ChartSettingsError::FontSize(
314                self.scales_appearance.font_size,
315            ));
316        }
317        if self.scales_and_lines.lock_price_to_bar_ratio
318            && (!self.scales_and_lines.price_to_bar_ratio.is_finite()
319                || self.scales_and_lines.price_to_bar_ratio <= 0.0)
320        {
321            return Err(ChartSettingsError::PriceToBarRatio(
322                self.scales_and_lines.price_to_bar_ratio,
323            ));
324        }
325        let volume = self.trading.execution_sound_volume;
326        if !volume.is_finite() || !(0.0..=1.0).contains(&volume) {
327            return Err(ChartSettingsError::ExecutionSoundVolume(volume));
328        }
329        let volume = self.alerts.sound_volume;
330        if !volume.is_finite() || !(0.0..=1.0).contains(&volume) {
331            return Err(ChartSettingsError::AlertSoundVolume(volume));
332        }
333        if let Some(params) = &self.chart_type_params
334            && let Some(error) = params.invalid(self.chart_type)
335        {
336            return Err(ChartSettingsError::ChartTypeParams {
337                chart_type: self.chart_type,
338                reason: error,
339            });
340        }
341        Ok(timezone)
342    }
343
344    /// Apply the display controls offered by the dialog without rebuilding the chart or feed.
345    /// Host-owned trading and alerts are left to their providers. Invalid timezone,
346    /// margins or crosshair width returns an error before changing the chart.
347    pub fn apply_to_chart(&self, chart: &mut Chart) -> Result<(), ChartSettingsError> {
348        let timezone = self.validate()?;
349        if let Some(params) = &self.chart_type_params {
350            if let Some(error) = params.invalid(chart.chart_type()) {
351                return Err(ChartSettingsError::ChartTypeParams {
352                    chart_type: chart.chart_type(),
353                    reason: error,
354                });
355            }
356            chart.set_chart_type_params(params.clone());
357        }
358        if let Some(source) = self.price_source {
359            chart.config_mut().price_source = source;
360        }
361        chart.set_liquidity_heatmap_config(self.order_flow.heatmap);
362        chart.set_liquidity_heatmap(self.order_flow.show_heatmap);
363        chart.set_order_book_view(self.order_flow.order_book.clone());
364        chart.set_order_book_visible(self.order_flow.show_order_book);
365        if let Some(link) = &mut chart.feed_link {
366            link.bubbles_config = self.order_flow.volume_bubbles.clone();
367        }
368        chart.set_volume_bubbles(
369            self.order_flow
370                .show_volume_bubbles
371                .then(|| self.order_flow.volume_bubbles.clone()),
372        );
373        chart.set_event_visibility(self.events.clone());
374        chart.set_price_targets_visible(self.show_price_targets);
375        chart.chart_options.time_scale.timezone = timezone;
376        if self.scales_appearance.lines_color != axis_line_color(chart.config()) {
377            // A transparent line is no line; any other colour draws on the price axes and the time axis.
378            let line = (self.scales_appearance.lines_color != Color32::TRANSPARENT)
379                .then_some(self.scales_appearance.lines_color);
380            chart.config_mut().price_axis.border_visible = line.is_some();
381            chart.config_mut().price_axis.border_color = line;
382            chart.chart_options.time_scale.border_visible = line.is_some();
383            chart.chart_options.time_scale.border_color = line;
384        }
385        let crosshair = &mut chart.chart_options.crosshair;
386        crosshair.horz_line_color = self.crosshair.color;
387        crosshair.vert_line_color = self.crosshair.color;
388        crosshair.horz_line_width = self.crosshair.line_width;
389        crosshair.vert_line_width = self.crosshair.line_width;
390        crosshair.horz_line_style = self.crosshair.line_style;
391        crosshair.vert_line_style = crosshair.horz_line_style;
392        let config = chart.config_mut();
393        config.bullish_color = self.candle_colors.body_up;
394        config.bearish_color = self.candle_colors.body_down;
395        config.bullish_border_color = Some(self.candle_colors.border_up);
396        config.bearish_border_color = Some(self.candle_colors.border_down);
397        config.bullish_wick_color = Some(self.candle_colors.wick_up);
398        config.bearish_wick_color = Some(self.candle_colors.wick_down);
399        // Borders keep a width the host chose and take a hairline when switched on.
400        if !self.candle_colors.show_border {
401            config.candle_border_width = 0.0;
402        } else if config.candle_border_width <= 0.0 {
403            config.candle_border_width = 1.0;
404        }
405
406        if self.chart_basic_styles.background_color != Color32::PLACEHOLDER {
407            config.background_color = self.chart_basic_styles.background_color;
408        }
409        config.grid_color = self.grid_lines.horizontal_color;
410
411        let (h, v) = match self.grid_lines.mode {
412            GridLinesMode::None => (false, false),
413            GridLinesMode::Horizontal => (true, false),
414            GridLinesMode::Vertical => (false, true),
415            GridLinesMode::Both => (true, true),
416        };
417        config.show_horizontal_grid = h;
418        config.show_vertical_grid = v;
419        config.show_grid = h || v;
420
421        config.navigation_buttons = navigation_buttons(self.btns.navigation);
422        config.pane_buttons = navigation_buttons(self.btns.pane);
423        config.show_ohlc_info = self.status_line.show_ohlc;
424        config.show_legend_badge = self.status_line.show_logo;
425        config.show_market_status = self.status_line.show_market_status;
426        config.show_last_day_change = self.status_line.show_last_day_change;
427        config.indicator_backdrop_opacity =
428            f32::from(self.status_line.indicator_background_opacity.min(100)) / 100.0;
429        config.show_symbol_labels = self.status_line.show_symbol;
430        config.title_as_description = self.status_line.title_as_description;
431        config.show_quote_boxes = self.status_line.show_quote_boxes;
432        config.show_ohlc_change = self.status_line.show_change;
433        config.show_ohlc_change_percent = self.status_line.show_change_percent;
434        config.show_legend_volume = self.status_line.show_volume;
435        config.show_indicator_labels = self.status_line.show_indicator_titles;
436        config.show_indicator_inputs = self.status_line.show_indicator_inputs;
437        config.show_indicator_last_val = self.status_line.show_indicator_values;
438        config.show_right_axis = !self.scales_and_lines.price_scale_left;
439        config.show_left_axis = self.scales_and_lines.price_scale_left;
440        config.show_currency = self.scales_and_lines.show_currency;
441        config.show_unit = self.scales_and_lines.show_unit;
442        config.show_scale_mode_buttons = self.scales_and_lines.show_scale_mode_buttons;
443        config.show_plus_button = self.scales_and_lines.show_plus_button;
444        config.price_axis.allow_label_overlap = self.scales_and_lines.allow_label_overlap;
445        config.price_axis.show_study_values = self.scales_and_lines.show_study_values;
446        config.price_axis.study_value_color = self.scales_and_lines.study_value_color;
447        config.price_axis.show_symbol_name = self.scales_and_lines.show_symbol_name;
448        config.price_axis.show_study_names = self.scales_and_lines.show_study_names;
449        config.show_extended_session_price = self.scales_and_lines.show_extended_session_price;
450        config.extended_session_price_color = self.scales_and_lines.extended_session_price_color;
451        config.lock_price_to_bar_ratio = self.scales_and_lines.lock_price_to_bar_ratio;
452        config.show_symbol_last_val = self.scales_and_lines.show_last_price_label;
453        config.show_price_line = self.scales_and_lines.show_price_line;
454        config.show_symbol_prev_close = self.scales_and_lines.show_prev_close_label;
455        config.show_prev_close_line = self.scales_and_lines.show_prev_close;
456        config.show_countdown = self.scales_and_lines.show_countdown;
457        config.show_bid_ask = self.scales_and_lines.show_bid_ask;
458        config.show_high_low = self.scales_and_lines.show_high_low;
459
460        config.vertical_grid_color = Some(self.grid_lines.vertical_color);
461        let resolved = |color| {
462            if color == Color32::PLACEHOLDER {
463                config.background_color
464            } else {
465                color
466            }
467        };
468        let first = resolved(self.chart_basic_styles.background_gradient_top);
469        let second = resolved(self.chart_basic_styles.background_gradient_bottom);
470        config.background_style = match self.chart_basic_styles.background_type {
471            BackgroundType::Solid => BackgroundStyle::Solid,
472            BackgroundType::VerticalGradient => BackgroundStyle::VerticalGradient {
473                top_color: first,
474                bottom_color: second,
475            },
476            BackgroundType::HorizontalGradient => BackgroundStyle::HorizontalGradient {
477                left_color: first,
478                right_color: second,
479            },
480        };
481        config.text_color = self.scales_appearance.text_color;
482        // Values the dialog did not change keep following the theme.
483        if self.scales_appearance.font_size != axis_font_size(config) {
484            config.axis_font_size = Some(f32::from(self.scales_appearance.font_size));
485        }
486        config.show_watermark = self.watermark.mode != WatermarkMode::ReplayMode;
487        config.watermark_color = self.watermark.color;
488        if self.watermark.mode != WatermarkMode::Custom {
489            config.watermark_text = None;
490            config.watermark_description =
491                self.watermark.mode == WatermarkMode::SymbolAndDescription;
492        }
493        chart.price_scale_mut().options_mut().scale_margins = crate::scales::PriceScaleMargins {
494            top: f64::from(self.margins.top_percent.clamp(0.0, 100.0)) / 100.0,
495            bottom: f64::from(self.margins.bottom_percent.clamp(0.0, 100.0)) / 100.0,
496        };
497        if self.margins.right_bars != chart.chart_options().time_scale.right_offset.max(0.0) as u32
498        {
499            chart.set_right_margin(self.margins.right_bars as f32);
500        }
501        let mode = if self.scales_and_lines.log_scale {
502            PriceScaleMode::Logarithmic
503        } else if self.scales_and_lines.percentage_scale {
504            PriceScaleMode::Percentage
505        } else if chart.state.price_scale().options().mode == PriceScaleMode::IndexedTo100 {
506            PriceScaleMode::IndexedTo100
507        } else {
508            PriceScaleMode::Normal
509        };
510        // The mode belongs to the axis the main scale is drawn on, which the configured mode
511        // would otherwise override at the next frame.
512        let config = chart.config_mut();
513        if config.show_left_axis && !config.show_right_axis {
514            config.left_axis_scale_mode = mode;
515        } else {
516            config.right_axis_scale_mode = mode;
517        }
518        chart.applied_config_scale_mode = mode;
519        if mode != chart.state.price_scale().options().mode {
520            chart.state.price_scale_mut().reset();
521            chart.state.clear_zoom_history();
522        }
523        if self.scales_and_lines.auto_scale {
524            chart.state.price_scale_mut().reset();
525        } else if !chart.state.price_scale().is_manual() {
526            let range = chart.state.price_scale().range();
527            chart
528                .state
529                .price_scale_mut()
530                .set_range(range.min, range.max);
531        }
532        let options = chart.state.price_scale_mut().options_mut();
533        options.mode = mode;
534        options.invert_scale = self.scales_and_lines.invert_scale;
535        let precision = self
536            .precision
537            .decimals()
538            .map(|value| value as u8)
539            .or_else(|| {
540                chart
541                    .instrument()
542                    .and_then(crate::scales::instrument_price_precision)
543            });
544        chart.state.price_scale_mut().set_price_precision(precision);
545        if let Some(style) = chart.series_style_mut(crate::widget::SeriesId::MAIN) {
546            style.price_line_color = self.scales_and_lines.symbol_price_color;
547        }
548        chart.price_scale_mut().set_bar_ratio(
549            self.scales_and_lines
550                .lock_price_to_bar_ratio
551                .then_some(self.scales_and_lines.price_to_bar_ratio),
552        );
553        chart.last_ratio_lock = self.scales_and_lines.lock_price_to_bar_ratio;
554        let candles = CandlesStyle {
555            body_visible: self.candle_colors.show_body,
556            wick_visible: self.candle_colors.show_wick,
557            color_by_previous_close: self.candle_colors.color_by_previous_close,
558        };
559        // Other chart type parameters re-resolve their box sizes when set, so only a change is set.
560        if chart.chart_type_params().styles.candles != candles {
561            let mut params = chart.chart_type_params().clone();
562            params.styles.candles = candles;
563            chart.set_chart_type_params(params);
564        }
565        Ok(())
566    }
567}
568
569#[cfg(all(test, feature = "studies"))]
570mod tests {
571    use super::*;
572    use crate::model::{Bar, BarData, Instrument, quote::SessionQuote};
573    use crate::studies::{CustomIndicator, IndicatorValue};
574    use crate::theme::Theme;
575    use egui::{Context, Shape, Vec2};
576
577    #[cfg(feature = "drawings")]
578    struct DepthFeed;
579
580    #[cfg(feature = "drawings")]
581    impl crate::data::Datafeed for DepthFeed {
582        fn resolve(&self, symbol: &str, reply: crate::data::Reply<Instrument>) {
583            reply.send(Ok(Instrument::new(symbol).with_tick_size(1.0)));
584        }
585
586        fn history(
587            &self,
588            _: crate::data::HistoryRequest,
589            reply: crate::data::Reply<crate::data::HistoryPage>,
590        ) {
591            reply.send(Ok(crate::data::HistoryPage::new(
592                vec![Bar::new(
593                    chrono::DateTime::UNIX_EPOCH,
594                    100.0,
595                    110.0,
596                    90.0,
597                    105.0,
598                    0.0,
599                )],
600                true,
601            )));
602        }
603
604        fn subscribe(&self, subscription: crate::data::Subscription) {
605            subscription.sink.push_depth_snapshot(
606                1,
607                crate::model::OrderBook {
608                    ts: chrono::DateTime::UNIX_EPOCH,
609                    symbol: subscription.symbol,
610                    bids: vec![crate::model::OrderBookLevel {
611                        price: 99.0,
612                        size: 10.0,
613                        order_cnt: None,
614                    }],
615                    asks: vec![],
616                },
617            );
618        }
619    }
620
621    /// Saved layers and disabled preferences survive a layout round trip, while each book
622    /// profile follows its own switch, irrespective of the heatmap or the chart type.
623    #[test]
624    #[cfg(feature = "drawings")]
625    fn order_flow_settings_round_trip_and_render_independently() {
626        use crate::chart::overlays::{BubbleColor, BubbleGrouping, BubbleScalingMode};
627        use crate::widget::{ChartBuilder, ChartSnapshot};
628        for (kind, clamp_std_devs) in [
629            (crate::model::ChartType::Candles, 3.0),
630            (crate::model::ChartType::Line, f64::INFINITY),
631        ] {
632            for flags in 0..8 {
633                let ctx = Context::default();
634                let mut chart = ChartBuilder::new()
635                    .with_feed(std::sync::Arc::new(DepthFeed))
636                    .with_symbol("A")
637                    .with_timeframe(crate::model::Timeframe::Min1)
638                    .with_chart_type(kind)
639                    .build();
640                chart.set_size(Some(Vec2::new(800.0, 600.0)));
641                for _ in 0..4 {
642                    ctx.run_ui(Default::default(), |ui| {
643                        chart.show(ui);
644                    })
645                    .drop_without_applying_deltas();
646                }
647                let mut settings = ChartSettingsState::from_chart(&chart);
648                assert_eq!(
649                    settings.order_flow,
650                    super::super::OrderFlowSettings::default()
651                );
652                let flow = &mut settings.order_flow;
653                flow.show_heatmap = flags & 1 != 0;
654                flow.show_order_book = flags & 2 != 0;
655                flow.show_volume_bubbles = flags & 4 != 0;
656                flow.heatmap.brightness = 0.5;
657                flow.heatmap.lower_cutoff = 2.0;
658                flow.heatmap.full_intensity_size = Some(20.0);
659                flow.volume_bubbles.color_by = BubbleColor::Split;
660                flow.volume_bubbles.grouping =
661                    BubbleGrouping::TimeWindow(chrono::TimeDelta::milliseconds(250));
662                flow.volume_bubbles.scaling_mode = BubbleScalingMode::SquareRoot;
663                flow.volume_bubbles.min_size_filter = Some(3.0);
664                flow.volume_bubbles.clamp_std_devs = clamp_std_devs;
665                settings.apply_to_chart(&mut chart).unwrap();
666                assert_eq!(chart.chart_type(), kind);
667                assert_eq!(
668                    ChartSettingsState::from_chart(&chart).order_flow,
669                    settings.order_flow
670                );
671                let output = ctx.run_ui(Default::default(), |ui| {
672                    chart.show(ui);
673                });
674                let book_drawn = output.shapes.iter().any(|shape| {
675                    matches!(&shape.shape,
676                    Shape::Rect(rect) if (rect.rect.width() - 60.0).abs() < 0.01
677                        && rect.fill == chart.config().bullish_color.gamma_multiply(0.7))
678                });
679                output.drop_without_applying_deltas();
680                assert_eq!(
681                    book_drawn, settings.order_flow.show_order_book,
682                    "flags={flags}"
683                );
684                let saved = chart.snapshot().unwrap();
685                let json = serde_json::to_value(&saved).unwrap();
686                let loaded: ChartSnapshot = serde_json::from_value(json.clone()).unwrap();
687                chart.restore(&loaded).unwrap();
688                assert_eq!(
689                    ChartSettingsState::from_chart(&chart).order_flow,
690                    settings.order_flow
691                );
692                let mut legacy = json;
693                legacy["settings"]
694                    .as_object_mut()
695                    .unwrap()
696                    .remove("order_flow");
697                chart
698                    .restore(&serde_json::from_value(legacy).unwrap())
699                    .unwrap();
700                assert_eq!(
701                    ChartSettingsState::from_chart(&chart).order_flow,
702                    super::super::OrderFlowSettings::default()
703                );
704            }
705        }
706    }
707
708    #[test]
709    fn scale_settings_render_values_and_preserve_the_entered_ratio() {
710        for theme in [Theme::light(), Theme::dark()] {
711            for (left, mode) in [
712                (false, PriceScaleMode::Normal),
713                (true, PriceScaleMode::Normal),
714                (false, PriceScaleMode::Logarithmic),
715                (true, PriceScaleMode::Logarithmic),
716                (false, PriceScaleMode::Percentage),
717                (true, PriceScaleMode::Percentage),
718            ] {
719                for width in [320.0, 800.0] {
720                    let locked_ratio = if mode == PriceScaleMode::Logarithmic {
721                        0.005
722                    } else {
723                        0.5
724                    };
725                    let ctx = Context::default();
726                    crate::theme::apply_to_egui(&ctx, &theme);
727                    let bars = (0..10)
728                        .map(|i| {
729                            Bar::new(
730                                chrono::DateTime::UNIX_EPOCH + chrono::Duration::minutes(i),
731                                100.0,
732                                120.0,
733                                90.0,
734                                112.0,
735                                0.0,
736                            )
737                        })
738                        .collect();
739                    let mut chart = Chart::new(BarData::from_bars(bars));
740                    chart.set_size(Some(Vec2::new(width, 600.0)));
741                    chart.set_instrument(Some(
742                        Instrument::new("SAMPLE")
743                            .with_currency("USD")
744                            .with_unit("contracts per barrel"),
745                    ));
746                    chart.set_session_quote(Some(SessionQuote::new(115.0, "EXT")));
747                    chart.add_indicator(Box::new(CustomIndicator::new("Constant", |bars| {
748                        vec![IndicatorValue::Single(110.0); bars.len()]
749                    })));
750                    for _ in 0..2 {
751                        ctx.run_ui(Default::default(), |ui| {
752                            chart.show(ui);
753                        })
754                        .drop_without_applying_deltas();
755                    }
756                    let mut settings = ChartSettingsState::from_chart(&chart);
757                    let scales = &mut settings.scales_and_lines;
758                    scales.price_scale_left = left;
759                    scales.log_scale = mode == PriceScaleMode::Logarithmic;
760                    scales.percentage_scale = mode == PriceScaleMode::Percentage;
761                    scales.show_scale_mode_buttons = false;
762                    scales.show_plus_button = false;
763                    scales.allow_label_overlap = true;
764                    scales.auto_scale = false;
765                    scales.lock_price_to_bar_ratio = true;
766                    scales.price_to_bar_ratio = locked_ratio;
767                    scales.symbol_price_color = Some(Color32::GOLD);
768                    scales.study_value_color = Some(Color32::PURPLE);
769                    scales.extended_session_price_color = Color32::LIGHT_RED;
770                    settings.apply_to_chart(&mut chart).unwrap();
771                    assert_eq!(
772                        ChartSettingsState::from_chart(&chart).scales_and_lines,
773                        settings.scales_and_lines
774                    );
775                    for (currency, unit, shown) in [
776                        (true, true, true),
777                        (true, false, true),
778                        (false, true, true),
779                        (false, false, false),
780                    ] {
781                        settings.scales_and_lines.show_currency = currency;
782                        settings.scales_and_lines.show_unit = unit;
783                        settings.scales_and_lines.show_study_values = shown;
784                        settings.scales_and_lines.show_last_price_label = shown;
785                        settings.scales_and_lines.show_extended_session_price = shown;
786                        settings.apply_to_chart(&mut chart).unwrap();
787                        for frame in 0..3 {
788                            let mut output = ctx.run_ui(Default::default(), |ui| {
789                                chart.show(ui);
790                            });
791                            let shapes = std::mem::take(&mut output.shapes);
792                            output.drop_without_applying_deltas();
793                            if frame == 2 {
794                                let plot = chart.last_rendered_price_rect;
795                                let expected = match (currency, unit) {
796                                    (true, true) => Some("USD · contracts per barrel"),
797                                    (true, false) => Some("USD"),
798                                    (false, true) => Some("contracts per barrel"),
799                                    _ => None,
800                                };
801                                let header = shapes.iter().find_map(|s| match &s.shape {
802                                    Shape::Text(t)
803                                        if [
804                                            "USD · contracts per barrel",
805                                            "USD",
806                                            "contracts per barrel",
807                                        ]
808                                        .contains(&t.galley.text()) =>
809                                    {
810                                        Some(t.galley.text())
811                                    }
812                                    _ => None,
813                                });
814                                assert_eq!(header, expected);
815                                if let Some(expected) = expected {
816                                    let header_fits = shapes.iter().any(|s| match &s.shape {
817                                        Shape::Text(t) if t.galley.text() == expected => {
818                                            s.clip_rect.contains_rect(egui::Rect::from_min_size(
819                                                t.pos,
820                                                t.galley.size(),
821                                            ))
822                                        }
823                                        _ => false,
824                                    });
825                                    assert!(header_fits, "unit header must fit its axis column");
826                                }
827                                for color in [Color32::GOLD, Color32::PURPLE, Color32::LIGHT_RED] {
828                                    let tag = shapes.iter().any(|s| matches!(&s.shape, Shape::Rect(r) if r.fill == color && (r.rect.right() <= plot.left() || r.rect.left() >= plot.right())));
829                                    assert_eq!(tag, shown, "{color:?}, left={left}");
830                                }
831                                // A 0.5 unit/bar ratio and 6 point bars over 600 points imply
832                                // a 50 unit span. Use actual plot height and spacing after layout.
833                                let scale = chart.state().price_scale();
834                                let span = scale.range().max - scale.range().min;
835                                let ratio = span
836                                    * f64::from(
837                                        chart.state().time_scale().bar_spacing() / plot.height(),
838                                    );
839                                assert!((ratio - locked_ratio).abs() < 1e-6, "{ratio}");
840                                if mode == PriceScaleMode::Logarithmic {
841                                    assert!(
842                                        (chart
843                                            .last_price_map
844                                            .transform()
845                                            .to_scale(std::f64::consts::E)
846                                            - 1.0)
847                                            .abs()
848                                            < 1e-12
849                                    );
850                                }
851                            }
852                        }
853                    }
854                    for invalid in [0.0, -1.0, f64::NAN, f64::INFINITY] {
855                        settings.scales_and_lines.price_to_bar_ratio = invalid;
856                        assert!(settings.apply_to_chart(&mut chart).is_err());
857                        assert_eq!(chart.state().price_scale().bar_ratio(), Some(locked_ratio));
858                    }
859                }
860            }
861        }
862    }
863
864    /// The saved scale font size, axis line colour and pane button visibility render on the main,
865    /// study and time axes and in a maximized pane, and read back from the chart.
866    #[test]
867    fn scale_appearance_and_pane_button_settings_render_and_read_back() {
868        use crate::studies::RSI;
869        use crate::widget::test_frames::{chart, frame, shapes};
870        for (visibility, maximized) in [
871            (ButtonVisibility::AlwaysVisible, false),
872            (ButtonVisibility::VisibleOnMouseOver, false),
873            (ButtonVisibility::VisibleOnMouseOver, true),
874            (ButtonVisibility::Hidden, false),
875            (ButtonVisibility::Hidden, true),
876        ] {
877            let ctx = Context::default();
878            let mut chart = chart(&[100.0, 105.0], &[110.0, 115.0]);
879            let study = chart.add_indicator(Box::new(RSI::new(14)));
880            frame(&ctx, &mut chart, 0.0, Vec::new());
881            chart.pane_layout.maximized = maximized.then(|| chart.series_pane(study).unwrap());
882            let mut settings = ChartSettingsState::from_chart(&chart);
883            settings.scales_appearance.font_size = 18;
884            settings.scales_appearance.lines_color = Color32::RED;
885            settings.btns.pane = visibility;
886            settings.apply_to_chart(&mut chart).unwrap();
887            assert_eq!(
888                ChartSettingsState::from_chart(&chart).scales_appearance,
889                settings.scales_appearance
890            );
891            assert_eq!(ChartSettingsState::from_chart(&chart).btns, settings.btns);
892            frame(&ctx, &mut chart, 0.1, Vec::new());
893            let painted = shapes(&ctx, &mut chart, 0.2, Vec::new());
894            let pane = chart.get_rendered_indicator_panes()[0].clone();
895            let plot = if maximized {
896                pane.chart_rect
897            } else {
898                chart.get_rendered_price_rect()
899            };
900            for rect in [plot, pane.chart_rect] {
901                let axis_text = painted.iter().filter_map(|shape| match shape {
902                    Shape::Text(text)
903                        if text.pos.x >= rect.right() && rect.y_range().contains(text.pos.y) =>
904                    {
905                        Some(text.galley.job.sections[0].format.font_id.size)
906                    }
907                    _ => None,
908                });
909                let sizes: Vec<_> = axis_text.collect();
910                assert!(
911                    !sizes.is_empty() && sizes.iter().all(|size| *size == 18.0),
912                    "{sizes:?}"
913                );
914                let line = painted.iter().any(|shape| {
915                    matches!(shape, Shape::LineSegment { points, stroke }
916                    if stroke.color == Color32::RED
917                        && points[0].x == points[1].x
918                        && points[0].x >= rect.right()
919                        && rect.y_range().contains(points[0].y.max(points[1].y) - 1.0))
920                });
921                assert!(line, "axis line beside {rect:?}");
922            }
923            let button = egui::Rect::from_min_size(
924                egui::Pos2::new(pane.chart_rect.right() - 22.0, pane.panel_rect.top() + 2.0),
925                Vec2::splat(20.0),
926            );
927            let menu = painted.iter().any(|shape| {
928                matches!(shape, Shape::Text(text)
929                if text.galley.text() == "..." && pane.panel_rect.contains(text.pos))
930            });
931            assert_eq!(menu, visibility == ButtonVisibility::AlwaysVisible);
932            let icon = painted
933                .iter()
934                .any(|shape| matches!(shape, Shape::Rect(rect) if button.contains_rect(rect.rect)));
935            assert_eq!(
936                icon,
937                visibility == ButtonVisibility::AlwaysVisible
938                    || (visibility == ButtonVisibility::VisibleOnMouseOver && maximized),
939                "{visibility:?}, maximized={maximized}"
940            );
941            // A layout saved with this size could not be restored.
942            settings.scales_appearance.font_size = 0;
943            assert!(settings.apply_to_chart(&mut chart).is_err());
944        }
945    }
946
947    /// Each watermark mode paints its lines for the symbol, interval and description shown, also
948    /// after they change without the settings being applied again, and reads back as chosen. At
949    /// the default font size of 48, custom text keeps that size; the symbol line is 96 and the
950    /// description 44, whose capitals are about 68 and 31 pixels high in the interface font.
951    #[test]
952    fn watermark_modes_follow_symbol_and_interval_changes() {
953        use crate::model::Timeframe;
954        use crate::widget::test_frames::{chart, shapes};
955        let color = Color32::from_rgb(1, 2, 3);
956        for mode in WatermarkMode::all().iter().copied() {
957            let ctx = Context::default();
958            let mut chart = chart(&[100.0], &[110.0]);
959            chart.config_mut().watermark_text =
960                (mode == WatermarkMode::Custom).then(|| "Desk 4\nNight".to_owned());
961            let mut settings = ChartSettingsState::from_chart(&chart);
962            settings.watermark.mode = mode;
963            settings.watermark.color = color;
964            settings.apply_to_chart(&mut chart).unwrap();
965            for (symbol, interval, description, title) in [
966                ("AAPL", Timeframe::Day1, "Apple Inc", "AAPL, 1D"),
967                ("MSFT", Timeframe::Day1, "Microsoft Corp", "MSFT, 1D"),
968                ("MSFT", Timeframe::Hour4, "Microsoft Corp", "MSFT, 4h"),
969            ] {
970                chart.set_symbol(symbol);
971                chart.set_timeframe(interval);
972                chart.set_instrument(Some(Instrument::new(symbol).with_description(description)));
973                let painted: Vec<_> = shapes(&ctx, &mut chart, 0.0, Vec::new())
974                    .into_iter()
975                    .filter_map(|shape| match shape {
976                        Shape::Text(text) if text.fallback_color == color => Some((
977                            text.galley.text().to_owned(),
978                            text.galley.job.sections[0].format.font_id.size,
979                        )),
980                        _ => None,
981                    })
982                    .collect();
983                let expected: Vec<(String, f32)> = match mode {
984                    WatermarkMode::ReplayMode => vec![],
985                    WatermarkMode::Symbol => vec![(title.into(), 96.0)],
986                    WatermarkMode::SymbolAndDescription => {
987                        vec![(title.into(), 96.0), (description.into(), 44.0)]
988                    }
989                    WatermarkMode::Custom => vec![("Desk 4".into(), 48.0), ("Night".into(), 48.0)],
990                };
991                assert_eq!(painted, expected, "{mode:?} {title}");
992                assert_eq!(
993                    ChartSettingsState::from_chart(&chart).watermark.mode,
994                    mode,
995                    "{title}"
996                );
997            }
998        }
999    }
1000}