Skip to main content

egui_charts/ui/dialogs/series_settings/
dialog.rs

1//! Series settings dialog with sidebar navigation.
2
3use crate::chart::series::{SeriesId, SeriesSettings};
4use crate::localization as l10n;
5use egui::{Context, Ui, Vec2};
6
7use super::actions::SeriesSettingsAction;
8use super::config::SeriesSettingsConfig;
9use super::tabs::{CanvasTab, ScalesTab, SeriesSettingsTab, StatusLineTab, SymbolTab};
10use crate::theme::ThemeExt;
11use crate::tokens::DESIGN_TOKENS;
12use crate::ui_kit::dialog::DialogFooter;
13use crate::ui_kit::ext::UiExt;
14use crate::ui_kit::modal::Modal;
15use crate::ui_kit::sidebar_layout::{SidebarLayout, SidebarTab};
16
17/// Series settings dialog with sidebar layout
18pub struct SeriesSettingsDialog {
19    /// Display and layout options for this component.
20    pub config: SeriesSettingsConfig,
21    /// Whether the dialog is currently visible.
22    pub is_open: bool,
23    /// Identifier of the series being edited, when open.
24    pub series_id: Option<SeriesId>,
25    /// Currently selected tab.
26    pub active_tab: SeriesSettingsTab,
27    /// Editable settings currently shown by the dialog.
28    pub settings: SeriesSettings,
29    /// Settings captured when the dialog opened.
30    pub original_settings: SeriesSettings,
31}
32
33impl Default for SeriesSettingsDialog {
34    fn default() -> Self {
35        Self::new()
36    }
37}
38
39impl SeriesSettingsDialog {
40    /// Create a [`SeriesSettingsDialog`] with default settings.
41    pub fn new() -> Self {
42        Self {
43            config: SeriesSettingsConfig::default(),
44            is_open: false,
45            series_id: None,
46            active_tab: SeriesSettingsTab::default(),
47            settings: SeriesSettings::default(),
48            original_settings: SeriesSettings::default(),
49        }
50    }
51
52    /// Open the dialog with the supplied state.
53    pub fn open(&mut self, id: SeriesId, settings: SeriesSettings) {
54        self.is_open = true;
55        self.series_id = Some(id);
56        self.settings = settings.clone();
57        self.original_settings = settings;
58        self.active_tab = SeriesSettingsTab::default();
59    }
60
61    /// Close the dialog.
62    pub fn close(&mut self) {
63        self.is_open = false;
64        self.series_id = None;
65    }
66
67    /// Sync dialog open/close state from an external flag.
68    ///
69    /// Opening requires series context, so only close is synced here.
70    pub fn sync_open_state(&mut self, should_be_open: bool) {
71        if !should_be_open && self.is_open {
72            self.close();
73        }
74    }
75
76    /// Show the dialog and return the requested action.
77    pub fn show(&mut self, ctx: &Context) -> SeriesSettingsAction {
78        if !self.is_open {
79            return SeriesSettingsAction::None;
80        }
81
82        let mut action = SeriesSettingsAction::None;
83
84        let object = if self.series_id == Some(SeriesId::MAIN) {
85            "Main series".to_owned()
86        } else {
87            format!("Series {:?}", self.series_id)
88        };
89        let title = format!("{object} ยท {}", self.settings.chart_type.name());
90        let mut open = true;
91        Modal::new("series_settings", title)
92            .width(self.config.width + 32.0)
93            .height(self.config.height + 32.0)
94            .show(ctx, &mut open, |ui| {
95                action = self.render_contents(ui);
96            });
97        if !open {
98            self.is_open = false;
99            action = SeriesSettingsAction::Cancel;
100        }
101
102        action
103    }
104
105    fn render_contents(&mut self, ui: &mut Ui) -> SeriesSettingsAction {
106        let mut action = SeriesSettingsAction::None;
107
108        // Main content: sidebar + content area
109        let sidebar_width = DESIGN_TOKENS.sizing.dialog.series_sidebar_width;
110        let active_tab = self.active_tab;
111        let active_tab_mut = &mut self.active_tab;
112        let settings = &mut self.settings;
113
114        // Reserve the footer before the sidebar layout consumes the remaining height.
115        let body_height = (ui.available_height() - 64.0).max(0.0);
116        ui.allocate_ui(Vec2::new(ui.available_width(), body_height), |ui| {
117            SidebarLayout::new(sidebar_width.min(ui.available_width() * 0.4)).show(
118                ui,
119                |ui| {
120                    egui::ScrollArea::vertical()
121                        .id_salt("series_tabs")
122                        .show(ui, |ui| {
123                            ui.space_sm();
124                            for tab in
125                                [SeriesSettingsTab::Symbol, SeriesSettingsTab::ScalesAndLines]
126                            {
127                                if SidebarTab::new(tab.label(), *active_tab_mut == tab)
128                                    .icon(tab.icon())
129                                    .show(ui)
130                                {
131                                    *active_tab_mut = tab;
132                                }
133                            }
134                        });
135                },
136                |ui| {
137                    egui::ScrollArea::both()
138                        .id_salt("series_content")
139                        .show(ui, |ui| {
140                            ui.space_md();
141                            match active_tab {
142                                SeriesSettingsTab::Symbol => SymbolTab::show(ui, settings),
143                                SeriesSettingsTab::StatusLine => StatusLineTab::show(ui, settings),
144                                SeriesSettingsTab::ScalesAndLines => ScalesTab::show(ui, settings),
145                                SeriesSettingsTab::Canvas => CanvasTab::show(ui, settings),
146                            }
147                        });
148                },
149            );
150        });
151
152        // Footer
153        let (ok, cancel) = DialogFooter::new("Ok")
154            .secondary("Cancel")
155            .show_with_left(ui, |ui| {
156                if ui
157                    .button(l10n::text(ui.ctx(), "Reset to defaults"))
158                    .clicked()
159                {
160                    let theme = ui.active_theme();
161                    self.settings = SeriesSettings {
162                        chart_type: self.settings.chart_type,
163                        bullish_color: theme.chart.candle_up,
164                        bearish_color: theme.chart.candle_down,
165                        ..Default::default()
166                    };
167                }
168            });
169        if ok && let Some(id) = self.series_id {
170            action = SeriesSettingsAction::Apply(id, Box::new(self.settings.clone()));
171            self.is_open = false;
172        }
173        if cancel {
174            action = SeriesSettingsAction::Cancel;
175            self.is_open = false;
176        }
177
178        action
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::model::ChartType;
186
187    #[test]
188    fn accepting_preserves_candle_colours_and_reset_uses_the_active_theme() {
189        for (_, theme) in crate::theme::presets() {
190            for reset in [false, true] {
191                let ctx = Context::default();
192                crate::theme::apply_to_egui(&ctx, &theme);
193                let mut chart = crate::ChartBuilder::new().with_theme(theme.clone()).build();
194                chart.config_mut().bullish_color = egui::Color32::YELLOW;
195                chart.config_mut().bearish_wick_color = Some(egui::Color32::BLUE);
196                let original = chart.series_settings();
197                let mut dialog = SeriesSettingsDialog::new();
198                dialog.open(SeriesId::MAIN, original.clone());
199                let mut frame = |events, label: &str| {
200                    let mut action = SeriesSettingsAction::None;
201                    let output = ctx.run_ui(
202                        egui::RawInput {
203                            screen_rect: Some(egui::Rect::from_min_size(
204                                egui::Pos2::ZERO,
205                                Vec2::new(1440.0, 900.0),
206                            )),
207                            events,
208                            ..Default::default()
209                        },
210                        |ui| action = dialog.show(ui.ctx()),
211                    );
212                    let pos = output.shapes.iter().find_map(|shape| match &shape.shape {
213                        egui::Shape::Text(text) if text.galley.text() == label => {
214                            Some(text.pos + text.galley.size() / 2.0)
215                        }
216                        _ => None,
217                    });
218                    output.drop_without_applying_deltas();
219                    (action, pos)
220                };
221                let mut click = |label: &str| {
222                    frame(vec![], label);
223                    let pos = frame(vec![], label).1.expect("footer control is visible");
224                    frame(vec![egui::Event::PointerMoved(pos)], label);
225                    let button = |pressed| egui::Event::PointerButton {
226                        pos,
227                        pressed,
228                        button: egui::PointerButton::Primary,
229                        modifiers: Default::default(),
230                    };
231                    frame(vec![button(true)], label);
232                    frame(vec![button(false)], label).0
233                };
234                if reset {
235                    click("Reset to defaults");
236                }
237                let SeriesSettingsAction::Apply(_, settings) = click("Ok") else {
238                    panic!("expected Apply")
239                };
240                chart.apply_series_settings(&settings);
241                let applied = chart.series_settings();
242                assert_eq!(
243                    applied.bullish_color,
244                    if reset {
245                        theme.chart.candle_up
246                    } else {
247                        original.bullish_color
248                    }
249                );
250                assert_eq!(
251                    applied.bearish_color,
252                    if reset {
253                        theme.chart.candle_down
254                    } else {
255                        original.bearish_color
256                    }
257                );
258                for (actual, expected) in [
259                    (applied.bullish_border_color, original.bullish_border_color),
260                    (applied.bearish_border_color, original.bearish_border_color),
261                    (applied.bullish_wick_color, original.bullish_wick_color),
262                    (applied.bearish_wick_color, original.bearish_wick_color),
263                ] {
264                    assert_eq!(actual, if reset { None } else { expected });
265                }
266            }
267        }
268    }
269
270    #[test]
271    fn type_controls_leave_the_footer_inside_short_and_narrow_windows() {
272        for dark in [false, true] {
273            for size in [
274                Vec2::new(1440.0, 868.0),
275                Vec2::new(770.0, 520.0),
276                Vec2::new(420.0, 360.0),
277            ] {
278                for kind in [ChartType::Area, ChartType::Baseline, ChartType::Renko] {
279                    let ctx = Context::default();
280                    let theme = if dark {
281                        crate::theme::Theme::dark()
282                    } else {
283                        crate::theme::Theme::light()
284                    };
285                    crate::theme::apply_to_egui(&ctx, &theme);
286                    let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size);
287                    let mut dialog = SeriesSettingsDialog::new();
288                    dialog.open(
289                        SeriesId::MAIN,
290                        SeriesSettings {
291                            chart_type: kind,
292                            ..Default::default()
293                        },
294                    );
295                    for frame in 0..3 {
296                        let output = ctx.run_ui(
297                            egui::RawInput {
298                                screen_rect: Some(screen),
299                                ..Default::default()
300                            },
301                            |ui| {
302                                dialog.show(ui.ctx());
303                            },
304                        );
305                        let ok = output.shapes.iter().find_map(|shape| match &shape.shape {
306                            egui::Shape::Text(text) if text.galley.text() == "Ok" => Some((
307                                shape.clip_rect,
308                                egui::Rect::from_min_size(text.pos, text.galley.size()),
309                            )),
310                            _ => None,
311                        });
312                        let inert_colour_controls = output.shapes.iter().any(|shape| {
313                            matches!(
314                                &shape.shape, egui::Shape::Text(text)
315                                if matches!(text.galley.text(), "Border" | "Wick")
316                            )
317                        });
318                        let inactive = output.shapes.iter().any(|shape| matches!(&shape.shape, egui::Shape::Text(text) if matches!(text.galley.text(), "Trading" | "Alerts" | "Events" | "Coming soon")));
319                        output.drop_without_applying_deltas();
320                        assert!(!inactive, "placeholder settings must not be offered");
321                        if frame > 0 {
322                            if kind == ChartType::Renko {
323                                assert!(
324                                    !inert_colour_controls,
325                                    "Renko must only offer colours its renderer reads"
326                                );
327                            }
328                            let ok = ok.expect("apply button is painted");
329                            assert!(
330                                screen.contains_rect(ok.1) && ok.0.contains_rect(ok.1),
331                                "{kind:?} {size:?}: footer {ok:?}"
332                            );
333                        }
334                    }
335                }
336            }
337        }
338    }
339}