Skip to main content

egui_charts/ui/dialogs/indicator_dialog/
settings.rs

1//! Settings for an existing study, staged until Apply.
2
3use crate::localization as l10n;
4use crate::model::LineStyle;
5use crate::model::Timeframe;
6use crate::studies::{Indicator, InputValue, PlotType, StudyInput, StudyScale, StudySettings};
7use crate::theme::Theme;
8use crate::ui_kit::{FormGrid, dialog::DialogFooter, modal::Modal};
9use crate::widget::SeriesId;
10
11#[derive(Clone, Copy, PartialEq, Eq, Default)]
12enum Tab {
13    #[default]
14    Inputs,
15    Style,
16    Visibility,
17}
18
19/// A complete edit for the host to apply to the original study id.
20pub struct StudyEdit {
21    /// Stable id of the applied study.
22    pub id: SeriesId,
23    /// Calculation input ids and their new values.
24    pub inputs: Vec<(String, InputValue)>,
25    /// Per-plot styles and value tags, levels, precision and interval visibility.
26    pub settings: StudySettings,
27}
28
29/// Inputs, Style and Visibility editor for an applied study. Closing or cancelling discards
30/// staged values; no study or output history is cloned while the dialog is open.
31#[derive(Default)]
32pub struct IndicatorSettingsDialog {
33    edit: Option<StudyEdit>,
34    inputs: Vec<StudyInput>,
35    title: String,
36    line_names: Vec<String>,
37    levels: Vec<(f64, &'static str)>,
38    tab: Tab,
39    current_interval: Option<Timeframe>,
40    sources: Vec<(crate::studies::StudySource, String)>,
41    overlay: bool,
42    price_scale: bool,
43}
44
45impl crate::ui_kit::tab_bar::TabLabel for Tab {
46    fn tab_label(&self) -> &str {
47        match self {
48            Self::Inputs => "Inputs",
49            Self::Style => "Style",
50            Self::Visibility => "Visibility",
51        }
52    }
53}
54
55/// A line pattern picker named for screen readers.
56fn pattern_combo(ui: &mut egui::Ui, pattern: &mut LineStyle) {
57    egui::ComboBox::from_id_salt("pattern")
58        .selected_text(l10n::text(ui.ctx(), pattern.name()))
59        .show_ui(ui, |ui| {
60            for choice in LineStyle::all() {
61                ui.selectable_value(pattern, *choice, l10n::text(ui.ctx(), choice.name()));
62            }
63        })
64        .response
65        .accessible_name(l10n::text(ui.ctx(), "Line pattern"));
66}
67
68impl IndicatorSettingsDialog {
69    /// Open on the current values of the study identified by `id`.
70    pub fn open(&mut self, id: SeriesId, study: &dyn Indicator, interval: Option<Timeframe>) {
71        self.edit = Some(StudyEdit {
72            id,
73            inputs: Vec::new(),
74            settings: study.settings(),
75        });
76        self.inputs = study.inputs();
77        self.title = format!("{} settings", study.name());
78        self.line_names = study.line_names();
79        self.levels = study
80            .pane()
81            .levels
82            .into_iter()
83            .map(|(value, _, caption)| (value, caption))
84            .collect();
85        self.overlay = study.is_overlay();
86        self.price_scale = study.scale() == StudyScale::Main;
87        self.current_interval = interval;
88        self.tab = Tab::Inputs;
89        self.sources.clear();
90    }
91
92    /// Open on a chart study and offer its other studies as source inputs, excluding cycles.
93    pub fn open_for_chart(&mut self, chart: &crate::widget::Chart, id: SeriesId) -> bool {
94        let Some((_, study)) = chart.indicators().find(|(candidate, _)| *candidate == id) else {
95            return false;
96        };
97        self.open(id, study, chart.timeframe());
98        let studies: Vec<_> = chart.indicators().collect();
99        for (candidate, study) in &studies {
100            let mut next = Some(candidate.0);
101            for _ in 0..=studies.len() {
102                let Some(current) = next else {
103                    break;
104                };
105                if current == id.0 {
106                    break;
107                }
108                next = studies
109                    .iter()
110                    .find(|(id, _)| id.0 == current)
111                    .and_then(|(_, study)| match study.source() {
112                        Some(crate::studies::StudySource::Study { id, .. }) => Some(id),
113                        _ => None,
114                    });
115            }
116            if next.is_some() {
117                continue;
118            }
119            for (plot, name) in study.line_names().into_iter().enumerate() {
120                self.sources.push((
121                    crate::studies::StudySource::Study {
122                        id: candidate.0,
123                        plot,
124                    },
125                    format!("{}: {name}", study.name()),
126                ));
127            }
128        }
129        true
130    }
131
132    /// Whether an edit is open.
133    pub fn is_open(&self) -> bool {
134        self.edit.is_some()
135    }
136
137    /// Show the staged edit. Apply returns it once; the host calls
138    /// [`Indicator::apply_settings`] on the original study, then lets the chart recalculate.
139    pub fn show(&mut self, ctx: &egui::Context, _theme: &Theme) -> Option<StudyEdit> {
140        let edit = self.edit.as_mut()?;
141        let mut open = true;
142        let mut apply = false;
143        let mut cancel = false;
144        Modal::new("indicator_settings", &self.title)
145            .width(620.0)
146            .height(560.0)
147            .top(120.0)
148            .show(ctx, &mut open, |ui| {
149                crate::ui_kit::tab_bar::TabBar::new(
150                    &[Tab::Inputs, Tab::Style, Tab::Visibility],
151                    &mut self.tab,
152                )
153                .show(ui);
154                ui.separator();
155                egui::ScrollArea::both()
156                    .max_height((ui.available_height() - 72.0).max(0.0))
157                    .show(ui, |ui| match self.tab {
158                        Tab::Inputs => {
159                            if self.inputs.is_empty() {
160                                ui.label(l10n::text(
161                                    ui.ctx(),
162                                    "This study has no editable inputs.",
163                                ));
164                            }
165                            if !self.inputs.is_empty()
166                                && ui
167                                    .small_button(l10n::text(ui.ctx(), "Reset inputs"))
168                                    .clicked()
169                            {
170                                for input in &mut self.inputs {
171                                    input.value = input.default.clone();
172                                }
173                            }
174                            let study_source = self.inputs.iter().any(|input| {
175                                matches!(
176                                    input.value,
177                                    InputValue::Source(crate::studies::StudySource::Study { .. })
178                                )
179                            });
180                            FormGrid::new("study_inputs").show(ui, |ui| {
181                                for input in &mut self.inputs {
182                                    if study_source
183                                        && matches!(
184                                            input.id.as_str(),
185                                            "calculation_symbol"
186                                                | "calculation_timeframe"
187                                                | "wait_for_close"
188                                        )
189                                    {
190                                        input.value = input.default.clone();
191                                        continue;
192                                    }
193                                    crate::ui::strategy_report::inputs::show_input(
194                                        ui,
195                                        input,
196                                        &self.sources,
197                                    );
198                                }
199                            });
200                        }
201                        Tab::Style => {
202                            for (line, plot) in edit.settings.plots.iter_mut().enumerate() {
203                                ui.push_id(line, |ui| {
204                                    ui.horizontal_wrapped(|ui| {
205                                        ui.checkbox(
206                                            &mut plot.visible,
207                                            l10n::text(
208                                                ui.ctx(),
209                                                self.line_names
210                                                    .get(line)
211                                                    .map_or("Plot", String::as_str),
212                                            ),
213                                        );
214                                        ui.color_edit_button_srgba(&mut plot.color);
215                                        ui.add(
216                                            egui::DragValue::new(&mut plot.width)
217                                                .range(0.5..=10.0)
218                                                .suffix(l10n::text(ui.ctx(), " px")),
219                                        )
220                                        .accessible_name(l10n::text(ui.ctx(), "Line width"));
221                                    });
222                                    ui.horizontal_wrapped(|ui| {
223                                        pattern_combo(ui, &mut plot.pattern);
224                                        egui::ComboBox::from_id_salt("plot_type")
225                                            .selected_text(l10n::text(
226                                                ui.ctx(),
227                                                plot.plot_type.name(),
228                                            ))
229                                            .show_ui(ui, |ui| {
230                                                for kind in PlotType::ALL {
231                                                    let label = l10n::text(ui.ctx(), kind.name());
232                                                    // A saved choice stays listed and selected.
233                                                    match kind
234                                                        .incompatibility(
235                                                            self.overlay,
236                                                            self.price_scale,
237                                                        )
238                                                        .filter(|_| kind != plot.plot_type)
239                                                    {
240                                                        Some(reason) => {
241                                                            ui.add_enabled(
242                                                                false,
243                                                                egui::Button::selectable(
244                                                                    false, label,
245                                                                ),
246                                                            )
247                                                            .on_disabled_hover_text(l10n::text(
248                                                                ui.ctx(),
249                                                                reason,
250                                                            ));
251                                                        }
252                                                        None => {
253                                                            ui.selectable_value(
254                                                                &mut plot.plot_type,
255                                                                kind,
256                                                                label,
257                                                            );
258                                                        }
259                                                    }
260                                                }
261                                            })
262                                            .response
263                                            .accessible_name(l10n::text(ui.ctx(), "Plot type"));
264                                        let mut tagged =
265                                            !edit.settings.untagged_plots.contains(&line);
266                                        if ui
267                                            .checkbox(
268                                                &mut tagged,
269                                                l10n::text(ui.ctx(), "Value on scale"),
270                                            )
271                                            .changed()
272                                        {
273                                            edit.settings
274                                                .untagged_plots
275                                                .retain(|&other| other != line);
276                                            if !tagged {
277                                                edit.settings.untagged_plots.push(line);
278                                            }
279                                        }
280                                    });
281                                    ui.separator();
282                                });
283                            }
284                            for (index, level) in edit.settings.levels.iter_mut().enumerate() {
285                                let (drawn, caption) = self
286                                    .levels
287                                    .get(index)
288                                    .copied()
289                                    .unwrap_or((f64::NAN, "Level"));
290                                ui.push_id(("level", index), |ui| {
291                                    ui.horizontal_wrapped(|ui| {
292                                        ui.checkbox(
293                                            &mut level.visible,
294                                            l10n::text(ui.ctx(), caption),
295                                        );
296                                        let mut value = level.value.unwrap_or(drawn);
297                                        if ui
298                                            .add(egui::DragValue::new(&mut value).speed(0.1))
299                                            .accessible_name(l10n::text(ui.ctx(), "Level value"))
300                                            .changed()
301                                        {
302                                            level.value = Some(value);
303                                        }
304                                        ui.color_edit_button_srgba(&mut level.color)
305                                            .accessible_name(l10n::text(ui.ctx(), "Level colour"));
306                                        pattern_combo(ui, &mut level.pattern);
307                                    });
308                                });
309                            }
310                            if edit.settings.levels.len() >= 2 {
311                                ui.horizontal_wrapped(|ui| {
312                                    ui.checkbox(
313                                        &mut edit.settings.fill_between_levels,
314                                        l10n::text(ui.ctx(), "Fill between levels"),
315                                    );
316                                    ui.color_edit_button_srgba(&mut edit.settings.level_fill_color)
317                                        .accessible_name(l10n::text(ui.ctx(), "Fill colour"));
318                                });
319                            }
320                            if !edit.settings.levels.is_empty() {
321                                ui.separator();
322                            }
323                            ui.horizontal_wrapped(|ui| {
324                                ui.label(l10n::text(ui.ctx(), "Precision"));
325                                let mut automatic = edit.settings.precision.is_none();
326                                if ui
327                                    .checkbox(&mut automatic, l10n::text(ui.ctx(), "Automatic"))
328                                    .changed()
329                                {
330                                    edit.settings.precision =
331                                        if automatic { None } else { Some(2) };
332                                }
333                                if let Some(precision) = &mut edit.settings.precision {
334                                    ui.add(
335                                        egui::DragValue::new(precision)
336                                            .range(0..=12)
337                                            .suffix(l10n::text(ui.ctx(), " decimals")),
338                                    )
339                                    .accessible_name(l10n::text(ui.ctx(), "Precision"));
340                                }
341                            });
342                        }
343                        Tab::Visibility => {
344                            ui.label(l10n::text(ui.ctx(), "Show this study on these intervals:"));
345                            let mut intervals = Timeframe::all();
346                            for interval in self
347                                .current_interval
348                                .iter()
349                                .chain(&edit.settings.hidden_intervals)
350                            {
351                                if !intervals.contains(interval) {
352                                    intervals.push(*interval);
353                                }
354                            }
355                            ui.horizontal_wrapped(|ui| {
356                                for interval in intervals {
357                                    let mut visible =
358                                        !edit.settings.hidden_intervals.contains(&interval);
359                                    if ui
360                                        .checkbox(
361                                            &mut visible,
362                                            l10n::text(ui.ctx(), interval.to_string()),
363                                        )
364                                        .changed()
365                                    {
366                                        edit.settings.hidden_intervals.retain(|tf| *tf != interval);
367                                        if !visible {
368                                            edit.settings.hidden_intervals.push(interval);
369                                        }
370                                    }
371                                }
372                            });
373                        }
374                    });
375                let valid = self.inputs.iter().all(|input| input.accepts(&input.value))
376                    && edit
377                        .settings
378                        .levels
379                        .iter()
380                        .all(|level| level.value.is_none_or(f64::is_finite));
381                (apply, cancel) = DialogFooter::new("Apply")
382                    .primary_enabled(valid)
383                    .secondary("Cancel")
384                    .show(ui);
385            });
386        if !open || cancel {
387            self.edit = None;
388            return None;
389        }
390        if apply {
391            let mut edit = self.edit.take()?;
392            edit.inputs = self
393                .inputs
394                .iter()
395                .map(|input| (input.id.clone(), input.value.clone()))
396                .collect();
397            return Some(edit);
398        }
399        None
400    }
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use crate::studies::{ConfiguredStudy, SMA};
407
408    #[test]
409    fn dialog_follows_the_window_when_it_shrinks() {
410        for theme in [Theme::light(), Theme::dark()] {
411            for (name, tab) in [
412                ("MA Ribbon", None),
413                ("MA Ribbon", Some(Tab::Inputs)),
414                ("MA Ribbon", Some(Tab::Style)),
415                ("MA Ribbon", Some(Tab::Visibility)),
416                ("Vol Profile FR", Some(Tab::Inputs)),
417            ] {
418                let ctx = egui::Context::default();
419                crate::theme::apply_to_egui(&ctx, &theme);
420                let study = crate::studies::IndicatorFactory::new()
421                    .create(name)
422                    .unwrap();
423                let mut dialog = IndicatorSettingsDialog::default();
424                dialog.open(SeriesId(2), study.as_ref(), Some(Timeframe::Min1));
425                let mut browser = super::super::IndicatorDialog::new();
426                browser.open();
427                if let Some(tab) = tab {
428                    dialog.tab = tab;
429                }
430                for size in [
431                    egui::vec2(1440.0, 900.0),
432                    egui::vec2(320.0, 360.0),
433                    egui::vec2(600.0, 300.0),
434                ] {
435                    let size = if tab.is_none() {
436                        egui::vec2(size.x.max(600.0), size.y.max(500.0))
437                    } else {
438                        size
439                    };
440                    let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size);
441                    for _ in 0..4 {
442                        ctx.run_ui(
443                            egui::RawInput {
444                                screen_rect: Some(screen),
445                                ..Default::default()
446                            },
447                            |ui| {
448                                if tab.is_some() {
449                                    dialog.show(ui.ctx(), &theme);
450                                } else {
451                                    browser.show(ui.ctx(), &theme);
452                                }
453                            },
454                        )
455                        .drop_without_applying_deltas();
456                    }
457                    let shown = ctx
458                        .memory(|m| {
459                            m.area_rect(egui::Id::unique(if tab.is_some() {
460                                "indicator_settings"
461                            } else {
462                                "indicator_dialog"
463                            }))
464                        })
465                        .unwrap();
466                    assert!(
467                        screen.contains_rect(shown),
468                        "{name} tab {}: {shown:?} outside {screen:?}",
469                        tab.map(|tab| tab as u8).unwrap_or(3)
470                    );
471                }
472            }
473        }
474    }
475
476    #[test]
477    fn escape_discards_staged_inputs_and_presentation() {
478        let study = ConfiguredStudy::new(Box::new(SMA::new(20)));
479        let mut dialog = IndicatorSettingsDialog::default();
480        dialog.open(SeriesId(2), &study, Some(Timeframe::Min1));
481        dialog.inputs[0].value = InputValue::Length(2);
482        dialog.edit.as_mut().unwrap().settings.plots[0].visible = false;
483        let ctx = egui::Context::default();
484        for escape in [false, true] {
485            let events = if escape {
486                vec![egui::Event::Key {
487                    key: egui::Key::Escape,
488                    physical_key: None,
489                    pressed: true,
490                    repeat: false,
491                    modifiers: egui::Modifiers::NONE,
492                }]
493            } else {
494                Vec::new()
495            };
496            ctx.run_ui(
497                egui::RawInput {
498                    events,
499                    ..Default::default()
500                },
501                |ui| {
502                    assert!(dialog.show(ui.ctx(), &Theme::dark()).is_none());
503                },
504            )
505            .drop_without_applying_deltas();
506        }
507        assert!(!dialog.is_open());
508        assert_eq!(study.inputs()[0].value, InputValue::Length(20));
509        assert!(study.plot_style(0).visible);
510    }
511}