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}