Skip to main content

egui_charts/ui/dialogs/drawing_properties/
mod.rs

1//! Drawing properties dialog - modal popup for editing drawing properties.
2//!
3//! Provides a properties dialog for drawings with tabs:
4//! - Style: Color, line width, line style, fill options
5//! - Inputs: Saved tool-specific levels, sizing and calculation settings
6//! - Coordinates: Price and time points
7//! - Visibility: Timeframe visibility settings
8//! - Text: Labels and font settings
9//!
10//! Open with [`DrawingPropertiesDialog::open_for_chart`] and call
11//! [`DrawingPropertiesDialog::show_and_apply`] each frame to apply validated edits in the library.
12//! Hosts persist [`crate::drawings::DrawingManager::tool_defaults`] and may attach storage to
13//! [`DrawingPropertiesDialog::templates`]. Delete and Clone are in the drawing context menu.
14
15mod actions;
16mod config;
17mod dialog;
18mod tabs;
19
20pub use actions::{DrawingId, DrawingPropertiesAction, DrawingProps};
21pub use config::DrawingPropertiesConfig;
22pub use dialog::DrawingPropertiesDialog;
23pub use tabs::{CoordinatePoint, CoordinatesState, PropertiesTab};
24
25#[cfg(test)]
26mod tests {
27    use super::*;
28    use crate::drawings::{ChartPoint, Drawing, DrawingToolType as Tool};
29    use crate::model::{Bar, BarData};
30    use crate::theme::Theme;
31    use crate::widget::{Chart, SeriesId};
32    use chrono::{TimeZone, Utc};
33
34    /// Runs a frame of the dialog and returns the text it laid out, with its bounds.
35    fn frame(
36        dialog: &mut DrawingPropertiesDialog,
37        chart: &mut Chart,
38        ctx: &egui::Context,
39        events: Vec<egui::Event>,
40    ) -> Vec<(String, egui::Rect)> {
41        let output = ctx.run_ui(
42            egui::RawInput {
43                screen_rect: Some(egui::Rect::from_min_size(
44                    egui::Pos2::ZERO,
45                    egui::vec2(1440.0, 900.0),
46                )),
47                events,
48                ..Default::default()
49            },
50            |ui| {
51                dialog.show_and_apply(ui.ctx(), chart);
52            },
53        );
54        let texts = output
55            .shapes
56            .iter()
57            .filter_map(|shape| match &shape.shape {
58                egui::Shape::Text(text) => Some((
59                    text.galley.job.text.clone(),
60                    egui::Rect::from_min_size(text.pos, text.galley.size()),
61                )),
62                _ => None,
63            })
64            .collect();
65        output.drop_without_applying_deltas();
66        texts
67    }
68
69    fn click(
70        dialog: &mut DrawingPropertiesDialog,
71        chart: &mut Chart,
72        ctx: &egui::Context,
73        point: egui::Pos2,
74    ) {
75        frame(dialog, chart, ctx, vec![egui::Event::PointerMoved(point)]);
76        let buttons = [true, false].map(|pressed| egui::Event::PointerButton {
77            pos: point,
78            button: egui::PointerButton::Primary,
79            pressed,
80            modifiers: egui::Modifiers::NONE,
81        });
82        frame(dialog, chart, ctx, buttons.to_vec());
83    }
84
85    fn enter(dialog: &mut DrawingPropertiesDialog, chart: &mut Chart, ctx: &egui::Context) {
86        for pressed in [true, false] {
87            let key = egui::Event::Key {
88                key: egui::Key::Enter,
89                physical_key: None,
90                pressed,
91                repeat: false,
92                modifiers: egui::Modifiers::NONE,
93            };
94            frame(dialog, chart, ctx, vec![key]);
95        }
96    }
97
98    /// Family pages fit both themes and narrow windows; coordinate drafts apply as one undoable edit.
99    #[test]
100    fn family_inputs_and_coordinates_apply_through_dialog() {
101        let ctx = egui::Context::default();
102        let mut chart = Chart::new(BarData::new());
103        let mut dialog = DrawingPropertiesDialog::new();
104        // Times 0, 60, 180 seconds: 120 is halfway between bars 1 and 2, so index 1.5.
105        chart.set_data(
106            SeriesId::MAIN,
107            [0, 60, 180]
108                .map(|seconds| {
109                    Bar::new(
110                        Utc.timestamp_opt(seconds, 0).unwrap(),
111                        100.0,
112                        120.0,
113                        90.0,
114                        110.0,
115                        1.0,
116                    )
117                })
118                .to_vec(),
119        );
120        for (tool, heading) in [
121            (Tool::FibonacciRetracement, "Fibonacci levels"),
122            (Tool::FibonacciExtension, "Fibonacci levels"),
123            (Tool::FibonacciChannel, "Fibonacci levels"),
124            (Tool::FibonacciWedge, "Fibonacci levels"),
125            (Tool::FibonacciTimeZones, "Fibonacci levels"),
126            (Tool::FibonacciSpeedFan, "Fibonacci levels"),
127            (Tool::TrendBasedFibTime, "Fibonacci levels"),
128            (Tool::FibonacciCircles, "Fibonacci levels"),
129            (Tool::FibonacciSpeedResistanceArcs, "Fibonacci levels"),
130            (Tool::Pitchfork, "Pitchfork levels"),
131            (Tool::SchiffPitchfork, "Pitchfork levels"),
132            (Tool::ModifiedSchiffPitchfork, "Pitchfork levels"),
133            (Tool::InsidePitchfork, "Pitchfork levels"),
134            (Tool::Pitchfan, "Pitchfork levels"),
135            (Tool::GannBox, "Price levels"),
136            (Tool::GannSquare, "Price levels"),
137            (Tool::GannFixed, "Price levels"),
138            (Tool::GannFan, "Fan lines"),
139            (Tool::LongPos, "Entry price"),
140            (Tool::ShortPos, "Entry price"),
141            (Tool::FixedRangeVolumeProfile, "Rows"),
142            (Tool::AnchoredVolumeProfile, "Rows"),
143            (Tool::AnchoredVWAP, "Band 1"),
144            (Tool::RegressionTrend, "Deviations"),
145            (Tool::ElliottImpulse, "Degree"),
146            (Tool::ElliottCorrection, "Degree"),
147            (Tool::ElliottTriangle, "Degree"),
148            (Tool::ElliottDoubleCombo, "Degree"),
149            (Tool::ElliottTripleCombo, "Degree"),
150        ] {
151            chart.drawings_mut().drawings.clear();
152            let mut drawing = Drawing::new(7, tool);
153            drawing.completed = true;
154            drawing.chart_points = vec![
155                ChartPoint::new(0.0, 100.0),
156                ChartPoint::new(2.0, 120.0),
157                ChartPoint::new(1.0, 95.0),
158            ];
159            let before = format!("{drawing:?}");
160            chart.drawings_mut().drawings.push(drawing);
161            dialog.open_for_chart(&chart, DrawingId(7));
162            assert_eq!(
163                dialog.coordinates.points.len(),
164                3,
165                "{tool:?}: every anchor must be editable"
166            );
167            frame(&mut dialog, &mut chart, &ctx, vec![]);
168            let texts = frame(&mut dialog, &mut chart, &ctx, vec![]);
169            let inputs = texts
170                .iter()
171                .find(|(text, _)| text == "Inputs")
172                .unwrap_or_else(|| panic!("{tool:?}: tool-specific Inputs tab: {texts:?}"))
173                .1
174                .center();
175            click(&mut dialog, &mut chart, &ctx, inputs);
176            let texts = frame(&mut dialog, &mut chart, &ctx, vec![]);
177            assert!(
178                texts.iter().any(|(text, _)| text == heading),
179                "{tool:?}: {texts:?}"
180            );
181            assert_eq!(
182                format!("{:?}", chart.drawings().drawings[0]),
183                before,
184                "visiting a page must not change defaults"
185            );
186            for (theme, width) in [(Theme::light(), 320.0), (Theme::dark(), 360.0)] {
187                let narrow = egui::Context::default();
188                crate::theme::apply_to_egui(&narrow, &theme);
189                let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 640.0));
190                let tabs = if tool == Tool::FibonacciRetracement {
191                    PropertiesTab::all().to_vec()
192                } else {
193                    vec![PropertiesTab::Inputs]
194                };
195                for tab in tabs {
196                    dialog.active_tab = tab;
197                    for pass in 0..3 {
198                        let mut output = narrow.run_ui(
199                            egui::RawInput {
200                                screen_rect: Some(screen),
201                                ..Default::default()
202                            },
203                            |ui| {
204                                dialog.show_and_apply(ui.ctx(), &mut chart);
205                            },
206                        );
207                        output.textures_delta.clear();
208                        if pass > 0 {
209                            let area = narrow
210                                .memory(|memory| {
211                                    memory.area_rect(egui::Id::unique(Some("Drawing Properties")))
212                                })
213                                .unwrap();
214                            assert!(
215                                screen.contains_rect(area),
216                                "{tool:?}, {tab:?}: dialog exceeds the window: {area:?}"
217                            );
218                        }
219                        let mut footer = Vec::new();
220                        for shape in &output.shapes {
221                            if let egui::Shape::Text(text) = &shape.shape
222                                && matches!(
223                                    text.galley.job.text.as_str(),
224                                    "Ok" | "Cancel" | "Template"
225                                )
226                            {
227                                let bounds =
228                                    egui::Rect::from_min_size(text.pos, text.galley.size());
229                                if matches!(
230                                    text.galley.job.text.as_str(),
231                                    "Ok" | "Cancel" | "Template"
232                                ) {
233                                    footer.push(bounds);
234                                }
235                                assert!(
236                                    screen.left() <= bounds.left()
237                                        && screen.right() >= bounds.right(),
238                                    "{tool:?}: control outside narrow window: {bounds:?}"
239                                );
240                                assert!(
241                                    shape.clip_rect.left() <= bounds.left()
242                                        && shape.clip_rect.right() >= bounds.right(),
243                                    "{tool:?}: horizontally clipped control: {bounds:?}, clip: {:?}",
244                                    shape.clip_rect
245                                );
246                            }
247                        }
248                        for (index, bounds) in footer.iter().enumerate() {
249                            assert!(
250                                footer[index + 1..]
251                                    .iter()
252                                    .all(|other| !bounds.intersects(*other)),
253                                "{tool:?}: overlapping footer controls: {footer:?}"
254                            );
255                        }
256                        output.drop_without_applying_deltas();
257                    }
258                }
259            }
260            dialog.close();
261        }
262        chart.drawings_mut().drawings[0].tool_type = Tool::TrendLine;
263        dialog.open_for_chart(&chart, DrawingId(7));
264        dialog.active_tab = PropertiesTab::Coordinates;
265        assert_eq!(dialog.coordinates.points[1].time, "00:03:00");
266        dialog.coordinates.points[1].price = "123.5".into();
267        dialog.coordinates.points[1].time = "00:02".into();
268        dialog.coordinates.points[0].price = "NaN".into();
269        frame(&mut dialog, &mut chart, &ctx, vec![]);
270        enter(&mut dialog, &mut chart, &ctx);
271        assert!(dialog.is_open, "invalid drafts remain editable");
272        assert_eq!(chart.drawings().drawings[0].chart_points[1].price, 120.0);
273        dialog.coordinates.points[0].price = "100".into();
274        enter(&mut dialog, &mut chart, &ctx);
275        assert!(
276            !dialog.is_open,
277            "{:?}",
278            frame(&mut dialog, &mut chart, &ctx, vec![])
279        );
280        assert_eq!(
281            chart.drawings().drawings[0].chart_points[1],
282            ChartPoint::new(1.5, 123.5)
283        );
284        assert!(chart.undo());
285        assert_eq!(
286            chart.drawings().drawings[0].chart_points[1],
287            ChartPoint::new(2.0, 120.0)
288        );
289        assert!(chart.redo());
290        dialog.open_for_chart(&chart, DrawingId(7));
291        dialog.coordinates.points[1].price = "124.5".into();
292        // History can arrive while the draft is open. The timestamp stays at 120 seconds,
293        // now halfway between indices 2 and 3; untouched anchors must move with history too.
294        chart.set_data(
295            SeriesId::MAIN,
296            [-60, 0, 60, 180]
297                .map(|seconds| {
298                    Bar::new(
299                        Utc.timestamp_opt(seconds, 0).unwrap(),
300                        100.0,
301                        120.0,
302                        90.0,
303                        110.0,
304                        1.0,
305                    )
306                })
307                .to_vec(),
308        );
309        frame(&mut dialog, &mut chart, &ctx, vec![]);
310        enter(&mut dialog, &mut chart, &ctx);
311        assert_eq!(
312            chart.drawings().drawings[0].chart_points[1],
313            ChartPoint::new(2.5, 124.5)
314        );
315        assert_eq!(chart.drawings().drawings[0].chart_points[0].bar_idx, 1.0);
316        chart.drawings_mut().drawings[0].visible = false;
317        dialog.open_for_chart(&chart, DrawingId(7));
318        dialog.props.line_width = 4.0;
319        let texts = frame(&mut dialog, &mut chart, &ctx, vec![]);
320        let menu = texts
321            .iter()
322            .find(|(text, _)| text == "Template")
323            .unwrap()
324            .1
325            .center();
326        click(&mut dialog, &mut chart, &ctx, menu);
327        let texts = frame(&mut dialog, &mut chart, &ctx, vec![]);
328        let save = texts
329            .iter()
330            .find(|(text, _)| text == "Save as default for this tool")
331            .unwrap()
332            .1
333            .center();
334        click(&mut dialog, &mut chart, &ctx, save);
335        enter(&mut dialog, &mut chart, &ctx);
336        chart.drawings_mut().start_drawing_with_coords(
337            Tool::TrendLine,
338            egui::pos2(0.0, 100.0),
339            |x| x,
340            |y| y as f64,
341        );
342        assert_eq!(
343            chart.drawings().curr_drawing.as_ref().unwrap().stroke_width,
344            4.0
345        );
346        assert_eq!(
347            chart
348                .drawings()
349                .curr_drawing
350                .as_ref()
351                .unwrap()
352                .chart_points
353                .len(),
354            1,
355            "defaults cannot copy anchors"
356        );
357        assert!(
358            chart.drawings().curr_drawing.as_ref().unwrap().visible,
359            "a hidden source must not make new drawings invisible"
360        );
361        assert!(!chart.drawings().drawings[0].visible);
362        chart.drawings_mut().cancel_curr_drawing();
363        chart.drawings_mut().start_drawing_with_coords(
364            Tool::HorizontalLine,
365            egui::pos2(0.0, 100.0),
366            |x| x,
367            |y| y as f64,
368        );
369        assert_eq!(
370            chart.drawings().drawings.last().unwrap().stroke_width,
371            2.0,
372            "defaults belong to one tool"
373        );
374    }
375}