1mod 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 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 #[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 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 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}