egui_charts/ui/dialogs/series_settings/
dialog.rs1use 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
17pub struct SeriesSettingsDialog {
19 pub config: SeriesSettingsConfig,
21 pub is_open: bool,
23 pub series_id: Option<SeriesId>,
25 pub active_tab: SeriesSettingsTab,
27 pub settings: SeriesSettings,
29 pub original_settings: SeriesSettings,
31}
32
33impl Default for SeriesSettingsDialog {
34 fn default() -> Self {
35 Self::new()
36 }
37}
38
39impl SeriesSettingsDialog {
40 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 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 pub fn close(&mut self) {
63 self.is_open = false;
64 self.series_id = None;
65 }
66
67 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 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 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 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 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}