1use super::tabs::CoordinatesState;
4use crate::drawings::domain::GANN_SQUARE_UNITS;
5use crate::drawings::validation::{MAX_PITCHFORK_LEVEL, MAX_STROKE_WIDTH};
6use crate::drawings::{Drawing, DrawingTemplate, DrawingToolType, TimeframeVisibility};
7use crate::model::LineStyle;
8use crate::styles::typography::FONT_SIZES;
9use crate::widget::Chart;
10use egui::Color32;
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
14pub struct DrawingId(pub usize);
15
16#[derive(Clone, Debug)]
18pub struct DrawingProps {
19 pub symbol: Option<String>,
21 pub tool_settings: Option<Box<DrawingTemplate>>,
23 pub tick_size: Option<f64>,
25 pub coordinates: CoordinatesState,
27 pub save_as_default: bool,
29 pub color: Color32,
31 pub line_width: f32,
33 pub line_style: LineStyle,
35 pub fill_color: Option<Color32>,
37 pub fill_enabled: bool,
39 pub extend_left: bool,
41 pub extend_right: bool,
43 pub label: String,
45 pub font_size: f32,
47 pub timeframe_visibility: TimeframeVisibility,
49}
50
51impl Default for DrawingProps {
52 fn default() -> Self {
53 Self {
54 symbol: None,
55 tick_size: None,
56 tool_settings: None,
57 coordinates: CoordinatesState::default(),
58 save_as_default: false,
59 color: crate::theme::Theme::default().ui.accent,
60 line_width: 1.0,
61 line_style: LineStyle::Solid,
62 fill_color: None,
63 fill_enabled: false,
64 extend_left: false,
65 extend_right: false,
66 label: String::new(),
67 font_size: 12.0,
68 timeframe_visibility: TimeframeVisibility::all(),
69 }
70 }
71}
72
73#[derive(Clone, Debug)]
75#[non_exhaustive]
76pub enum DrawingPropertiesAction {
77 None,
79 Cancel,
81 Apply(DrawingId, Box<DrawingProps>),
83 Delete(DrawingId),
85 Clone(DrawingId),
87}
88
89impl DrawingProps {
90 pub fn from_drawing(drawing: &Drawing, chart: &Chart) -> Self {
92 Self {
93 symbol: Some(chart.symbol().to_owned()),
94 tick_size: chart
95 .instrument()
96 .and_then(|instrument| instrument.tick_size),
97 tool_settings: Some(Box::new(DrawingTemplate::from_drawing(drawing))),
98 coordinates: CoordinatesState::from_drawing(drawing, chart),
99 color: drawing.color32(),
100 line_width: drawing.stroke_width,
101 line_style: drawing.line_style,
102 fill_color: drawing.fill_color32(),
103 fill_enabled: drawing.fill_color.is_some(),
104 extend_left: drawing.extend_left,
105 extend_right: drawing.extend_right,
106 font_size: drawing.font_size,
107 timeframe_visibility: drawing.timeframe_visibility.clone(),
108 label: drawing.text.clone().unwrap_or_default(),
109 ..Self::default()
110 }
111 }
112
113 pub fn apply_to_chart(&self, id: DrawingId, chart: &mut Chart) -> Result<(), &'static str> {
118 let edits = self.edited_drawings(id, chart)?;
119 chart.user_action(|chart| {
120 let manager = chart.drawings_mut();
121 if !manager.is_selected(id.0) {
122 manager.select(id.0);
123 }
124 let ids: Vec<_> = edits.iter().map(|drawing| drawing.id).collect();
125 manager.edit_drawings(&ids, |drawing| {
126 if let Some(edited) = edits.iter().find(|edited| edited.id == drawing.id) {
127 *drawing = edited.clone();
128 }
129 });
130 if self.save_as_default
131 && let Some(drawing) = manager.drawings.iter().find(|d| d.id == id.0)
132 {
133 let mut defaults = DrawingTemplate::from_drawing(drawing);
134 defaults.settings.visible = true;
135 manager.tool_defaults.insert(drawing.tool_type, defaults);
136 }
137 });
138 Ok(())
139 }
140
141 pub(super) fn edited_drawings(
145 &self,
146 id: DrawingId,
147 chart: &Chart,
148 ) -> Result<Vec<Drawing>, &'static str> {
149 if self
150 .symbol
151 .as_ref()
152 .is_some_and(|symbol| symbol != chart.symbol())
153 {
154 return Err("The chart symbol changed; reopen drawing properties");
155 }
156 self.validate()?;
157 let points = self.coordinates.resolve(chart)?;
158 let target = chart
159 .drawings()
160 .drawings
161 .iter()
162 .find(|d| d.id == id.0)
163 .ok_or("Drawing was removed")?;
164 if target.locked
165 && (self.save_as_default
166 || !chart.drawings().is_selected(id.0)
167 || !chart
168 .drawings()
169 .drawings
170 .iter()
171 .any(|drawing| !drawing.locked && chart.drawings().is_selected(drawing.id)))
172 {
173 return Err("Unlock the drawing before editing its properties");
174 }
175 let adds_position_stop = matches!(
176 target.tool_type,
177 crate::drawings::DrawingToolType::LongPos | crate::drawings::DrawingToolType::ShortPos
178 ) && target.chart_points.len() == 2
179 && points.len() == 3;
180 if !points.is_empty() && points.len() != target.chart_points.len() && !adds_position_stop {
181 return Err("Drawing anchors changed; reopen its properties");
182 }
183 let selected = chart.drawings().is_selected(id.0);
184 let fill = self
185 .fill_enabled
186 .then(|| self.fill_color.unwrap_or(self.color.gamma_multiply(0.2)));
187 let mut edits = Vec::new();
188 for mut drawing in chart
189 .drawings()
190 .drawings
191 .iter()
192 .filter(|drawing| {
193 !drawing.locked
194 && if selected {
195 chart.drawings().is_selected(drawing.id)
196 } else {
197 drawing.id == id.0
198 }
199 })
200 .cloned()
201 {
202 if self.color != target.color32() {
205 drawing.color = self.color.to_srgba_unmultiplied();
206 }
207 if self.line_width != target.stroke_width {
208 drawing.stroke_width = self.line_width;
209 }
210 if self.line_style != target.line_style {
211 drawing.line_style = self.line_style;
212 }
213 if self.extend_left != target.extend_left {
214 drawing.extend_left = self.extend_left;
215 }
216 if self.extend_right != target.extend_right {
217 drawing.extend_right = self.extend_right;
218 }
219 if self.label != target.text.as_deref().unwrap_or_default()
221 && (drawing.id == id.0 || drawing.tool_type.supports_text())
222 {
223 drawing.text = (!self.label.is_empty()).then(|| self.label.clone());
224 }
225 if fill != target.fill_color32() && drawing.fill_color32() != fill {
226 drawing.fill_color = fill.map(|color| color.to_srgba_unmultiplied());
227 }
228 if self.font_size != target.font_size {
229 drawing.font_size = self.font_size;
230 }
231 if self.timeframe_visibility != target.timeframe_visibility {
232 drawing.timeframe_visibility = self.timeframe_visibility.clone();
233 }
234 if let Some(template) = &self.tool_settings
235 && template.settings.tool_type == drawing.tool_type
236 && template.settings.custom_tool == drawing.custom_tool
237 {
238 let settings = &template.settings;
239 macro_rules! edited {
241 ($($field:ident),+ $(,)?) => {
242 $(if settings.$field != target.$field {
243 drawing.$field.clone_from(&settings.$field);
244 })+
245 };
246 }
247 edited!(
248 font_weight,
249 italic,
250 text_color,
251 background,
252 border,
253 border_color,
254 arrow_start_style,
255 arrow_end_style,
256 custom_settings,
257 fib_config,
258 pitchfork,
259 gann,
260 ghost_feed,
261 bars_pattern,
262 position_size,
263 position_sizing,
264 position_target_color,
265 position_stop_color,
266 volume_profile,
267 regression_trend,
268 vwap,
269 channel_midline,
270 price_axis_label,
271 time_axis_label,
272 elliott_degree,
273 );
274 let opacity = settings.image.map_or(255, |c| c.opacity);
276 if opacity != target.image.map_or(255, |c| c.opacity) {
277 drawing.image.get_or_insert_with(Default::default).opacity = opacity;
278 }
279 }
280 if drawing.id == id.0
281 && !points.is_empty()
282 && (drawing.chart_points.len() != points.len()
283 || drawing
284 .chart_points
285 .iter()
286 .zip(&points)
287 .any(|(a, b)| a != b || a.time_anchor != b.time_anchor))
288 {
289 drawing.chart_points = points.clone();
290 drawing.screen_anchor = None;
291 }
292 edits.push(drawing);
293 }
294 Ok(edits)
295 }
296
297 pub(crate) fn validate(&self) -> Result<(), &'static str> {
298 if !(self.line_width > 0.0
299 && self.line_width <= MAX_STROKE_WIDTH
300 && FONT_SIZES.contains(&self.font_size))
301 {
302 return Err("Line width must be above 0 and at most 100, font size 1 to 256");
303 }
304 if let Some(template) = &self.tool_settings {
305 let settings = &template.settings;
306 if settings
307 .fib_config
308 .as_ref()
309 .is_some_and(|c| c.levels.iter().any(|l| !l.value.is_finite()))
310 {
311 return Err("Fibonacci ratios must be finite");
312 }
313 if settings
314 .fib_config
315 .as_ref()
316 .is_some_and(|c| !(c.level_width > 0.0 && c.level_width <= MAX_STROKE_WIDTH))
317 {
318 return Err("Fibonacci widths must be above 0 and at most 100");
319 }
320 if settings.pitchfork.as_ref().is_some_and(|c| {
321 c.levels
322 .iter()
323 .any(|l| !(0.0..=MAX_PITCHFORK_LEVEL).contains(&l.value))
324 }) {
325 return Err("Pitchfork multiples must be between 0 and 100");
326 }
327 if settings.pitchfork.as_ref().is_some_and(|c| {
328 c.levels
329 .iter()
330 .any(|l| l.width.is_some_and(|w| !(w > 0.0 && w <= MAX_STROKE_WIDTH)))
331 }) {
332 return Err("Pitchfork widths must be above 0 and at most 100");
333 }
334 if let Some(c) = settings.gann.as_ref() {
335 let square = matches!(
336 settings.tool_type,
337 DrawingToolType::GannSquare | DrawingToolType::GannFixed
338 );
339 let max = if square { GANN_SQUARE_UNITS } else { 1.0 };
340 if c.price_levels
341 .iter()
342 .chain(&c.time_levels)
343 .any(|l| !(0.0..=max).contains(&l.value))
344 {
345 return Err(if square {
346 "Gann square levels must be between 0 and 5"
347 } else {
348 "Gann levels must be between 0 and 1"
349 });
350 }
351 if c.fan_ratios
352 .iter()
353 .chain(&c.arc_ratios)
354 .any(|l| !(l.value.is_finite() && l.value > 0.0))
355 {
356 return Err("Gann fan and arc ratios must be positive and finite");
357 }
358 }
359 if settings.ghost_feed.is_some_and(|c| !c.is_valid()) {
360 return Err(
361 "Ghost feed average range must be positive and noise nonnegative, both finite",
362 );
363 }
364 if !settings.position_sizing.is_valid() {
365 return Err(
366 "Position economics must be positive and finite; quantity step cannot be negative",
367 );
368 }
369 if let Some(size) = settings.position_size
370 && size.quantity(1.0, 0.0).is_none()
371 {
372 return Err("Position size must be positive and finite; risk cannot exceed 100%");
373 }
374 }
375 Ok(())
376 }
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382
383 #[test]
386 fn props_round_trip_visibility_fill_fonts_and_tool_settings() {
387 use crate::drawings::{Drawing, DrawingToolType, TimeframeVisibility};
388 use crate::model::{BarData, LineStyle};
389 use crate::widget::Chart;
390 let mut chart = Chart::new(BarData::new());
391 let mut drawing = Drawing::new(7, DrawingToolType::TextLabel);
392 drawing.line_style = LineStyle::Dotted;
393 drawing.fill_color = Some([51, 102, 153, 85]);
394 drawing.font_size = 24.0;
395 drawing.extend_left = true;
396 drawing.timeframe_visibility = TimeframeVisibility::specific(&["1h"]);
397 drawing.text = Some("Annotation".into());
398 drawing.locked = true;
399 chart.drawings_mut().drawings.push(drawing);
400 let props = DrawingProps::from_drawing(&chart.drawings().drawings[0], &chart);
401 assert_eq!(
402 props.timeframe_visibility,
403 TimeframeVisibility::specific(&["1h"])
404 );
405 assert!(props.fill_enabled);
406 assert_eq!(
407 props.fill_color.unwrap().to_srgba_unmultiplied(),
408 [51, 102, 153, 85]
409 );
410 assert_eq!(props.font_size, 24.0);
411 assert!(matches!(props.line_style, LineStyle::Dotted));
412 assert!(props.extend_left);
413 assert!(
414 DrawingProps {
415 font_size: 40.0,
416 ..props.clone()
417 }
418 .apply_to_chart(DrawingId(7), &mut chart)
419 .is_err()
420 );
421 assert_eq!(chart.drawings().drawings[0].font_size, 24.0);
422 chart.drawings_mut().edit_drawing(7, |d| d.locked = false);
423 assert!(
425 DrawingProps {
426 font_size: 300.0,
427 ..props.clone()
428 }
429 .apply_to_chart(DrawingId(7), &mut chart)
430 .is_err()
431 );
432 props.apply_to_chart(DrawingId(7), &mut chart).unwrap();
433 let mut second = chart.drawings().drawings[0].clone();
434 second.id = 8;
435 chart.drawings_mut().drawings.push(second);
436 chart.drawings_mut().select_drawings([7, 8]);
437 let mut visibility = TimeframeVisibility::default();
438 visibility.set_range(crate::model::TimeframeUnit::Hour, Some(2..=4));
439 let changed = DrawingProps {
440 timeframe_visibility: visibility,
441 font_size: 28.0,
442 fill_enabled: false,
443 ..props.clone()
444 };
445 changed.apply_to_chart(DrawingId(7), &mut chart).unwrap();
446 let saved = &chart.drawings().drawings[0];
447 assert!(saved.timeframe_visibility.is_visible_on("4h"));
448 assert!(!saved.timeframe_visibility.is_visible_on("1h"));
449 assert_eq!(saved.fill_color, None);
450 assert_eq!(saved.font_size, 28.0);
451 assert_eq!(chart.drawings().drawings[1].font_size, 28.0);
452 assert!(chart.undo());
453 assert!(
454 chart
455 .drawings()
456 .drawings
457 .iter()
458 .all(|d| d.font_size == 24.0)
459 );
460 assert!(chart.redo());
461
462 use crate::data::VolumeProfileConfig;
463 use crate::drawings::{
464 FibonacciConfig, GannConfig, GannLevel, PitchforkConfig, PositionSize, PositionSizing,
465 RegressionTrendConfig,
466 };
467 for tool in [
468 DrawingToolType::FibonacciRetracement,
469 DrawingToolType::Pitchfork,
470 DrawingToolType::GannBox,
471 DrawingToolType::LongPos,
472 DrawingToolType::ShortPos,
473 DrawingToolType::FixedRangeVolumeProfile,
474 DrawingToolType::RegressionTrend,
475 DrawingToolType::ParallelChannel,
476 DrawingToolType::Rect,
477 DrawingToolType::TrendLine,
478 DrawingToolType::Ray,
479 DrawingToolType::ExtendedLine,
480 DrawingToolType::InfoLine,
481 DrawingToolType::TrendAngle,
482 DrawingToolType::Arrow,
483 DrawingToolType::Path,
484 DrawingToolType::Polyline,
485 DrawingToolType::HorizontalLine,
486 DrawingToolType::HorizontalRay,
487 DrawingToolType::VerticalLine,
488 DrawingToolType::CrossLine,
489 ] {
490 chart.drawings_mut().drawings.clear();
491 chart.drawings_mut().drawings.push(Drawing::new(7, tool));
492 let mut peer = Drawing::new(8, DrawingToolType::TextLabel);
493 (peer.background, peer.border, peer.border_color) = (true, true, None);
495 peer.chart_points
496 .push(crate::drawings::ChartPoint::new(5.0, 75.0));
497 chart.drawings_mut().drawings.push(peer);
498 chart.drawings_mut().select_drawings([7, 8]);
499 let mut props = DrawingProps::from_drawing(&chart.drawings().drawings[0], &chart);
500 let output = egui::Context::default().run_ui(Default::default(), |ui| {
501 let mut pickers = [
502 crate::ui_kit::ColorPickerState::new(props.color),
503 crate::ui_kit::ColorPickerState::new(props.color),
504 ];
505 super::super::tabs::StyleTab::show(ui, &mut props, &mut pickers);
506 });
507 let labels: Vec<_> = output
508 .shapes
509 .iter()
510 .filter_map(|shape| match &shape.shape {
511 egui::Shape::Text(text) => Some(text.galley.job.text.as_str()),
512 _ => None,
513 })
514 .collect();
515 let line = tool.supports_line_ends();
516 assert_eq!(labels.contains(&"Line start"), line, "{tool:?}");
517 assert_eq!(labels.contains(&"Line end"), line, "{tool:?}");
518 let middle = tool.supports_middle_line();
519 assert_eq!(labels.contains(&"Middle line"), middle, "{tool:?}");
520 use DrawingToolType as T;
521 let (price, time) = (
522 matches!(tool, T::HorizontalLine | T::HorizontalRay | T::CrossLine),
523 matches!(tool, T::VerticalLine | T::CrossLine),
524 );
525 assert_eq!(labels.contains(&"Price"), price, "{tool:?}");
526 assert_eq!(labels.contains(&"Time"), time, "{tool:?}");
527 output.drop_without_applying_deltas();
528 let settings = &mut props.tool_settings.as_mut().unwrap().settings;
529 settings.fib_config = Some(FibonacciConfig {
530 reverse: true,
531 ..Default::default()
532 });
533 settings.pitchfork = Some(PitchforkConfig {
534 background_opacity: 75,
535 ..Default::default()
536 });
537 settings.gann = Some(GannConfig {
538 price_levels: [0.0, 0.5, 1.0]
539 .map(|v| GannLevel::new(v, [128, 128, 128, 255]))
540 .to_vec(),
541 time_levels: [0.25, 0.75]
542 .map(|v| GannLevel::new(v, [128, 128, 128, 255]))
543 .to_vec(),
544 ..Default::default()
545 });
546 settings.position_size = Some(PositionSize::RiskPercent {
547 account_size: 10_000.0,
548 percent: 1.0,
549 });
550 settings.position_sizing = PositionSizing {
551 point_value: 50.0,
552 lot_size: 10.0,
553 quantity_step: 0.01,
554 leverage: 5.0,
555 };
556 let sizing = settings.position_sizing;
557 settings.position_target_color = Some([40, 100, 220, 255]);
558 settings.position_stop_color = Some([220, 140, 40, 255]);
559 settings.volume_profile = VolumeProfileConfig::new(48, 0.8);
560 settings.regression_trend = RegressionTrendConfig::new(3.0);
561 settings.channel_midline = true;
562 settings.bars_pattern = Some(crate::drawings::BarsPatternConfig {
563 mirrored: true,
564 ..Default::default()
565 });
566 settings.price_axis_label = false;
567 settings.time_axis_label = false;
568 let mut vwap = crate::drawings::VwapConfig::default();
569 vwap.bands[2] = crate::drawings::VwapBand::new(true, 0.5).unwrap();
570 settings.vwap = Some(vwap);
571 settings.elliott_degree = crate::drawings::ElliottDegree::Minute;
572 settings.italic = true;
573 settings.text_color = Some([1, 2, 3, 255]);
574 settings.background = false;
575 settings.border = false;
576 settings.border_color = Some([4, 5, 6, 255]);
577 settings.arrow_start_style = crate::drawings::ArrowStyle::Arrow;
578 settings.arrow_end_style = crate::drawings::ArrowStyle::None;
579 let expected = format!("{:?}", settings.fib_config);
580 props.apply_to_chart(DrawingId(7), &mut chart).unwrap();
581 let saved = &chart.drawings().drawings[0];
582 assert_eq!(format!("{:?}", saved.fib_config), expected);
583 assert_eq!(saved.pitchfork.as_ref().unwrap().background_opacity, 75);
584 assert_eq!(
585 saved
586 .gann
587 .as_ref()
588 .unwrap()
589 .time_levels
590 .iter()
591 .map(|l| l.value)
592 .collect::<Vec<_>>(),
593 [0.25, 0.75]
594 );
595 assert_eq!(
596 saved.position_size.unwrap().quantity(100.0, 98.0),
597 Some(50.0)
598 );
599 assert_eq!(saved.volume_profile.unwrap().rows(), 48);
600 assert_eq!(saved.position_sizing, sizing);
601 assert_eq!(saved.position_target_color, Some([40, 100, 220, 255]));
602 assert_eq!(saved.position_stop_color, Some([220, 140, 40, 255]));
603 assert_eq!(saved.regression_trend.unwrap().deviations(), 3.0);
604 assert!(saved.channel_midline);
605 assert!(saved.bars_pattern.unwrap().mirrored);
606 assert!(
607 !saved.price_axis_label && !saved.time_axis_label,
608 "{tool:?}"
609 );
610 assert_eq!(saved.vwap, Some(vwap));
611 assert_eq!(saved.elliott_degree, crate::drawings::ElliottDegree::Minute);
612 assert!(saved.italic && !saved.background && !saved.border);
613 assert_eq!(saved.text_color, Some([1, 2, 3, 255]));
614 assert_eq!(saved.border_color, Some([4, 5, 6, 255]));
615 assert_eq!(saved.arrow_start_style, crate::drawings::ArrowStyle::Arrow);
616 assert_eq!(saved.arrow_end_style, crate::drawings::ArrowStyle::None);
617 assert!(chart.drawings().drawings[1].fib_config.is_none());
618 let peer = &chart.drawings().drawings[1];
619 assert!(!peer.italic && peer.background && peer.border);
620 assert!(peer.price_axis_label && peer.time_axis_label);
621 assert_eq!((peer.text_color, peer.border_color), (None, None));
622 assert_eq!(chart.drawings().drawings[1].chart_points[0].price, 75.0);
623 assert!(chart.undo());
624 assert_eq!(
625 format!("{:?}", chart.drawings().drawings[0].fib_config),
626 format!("{:?}", Drawing::new(7, tool).fib_config)
627 );
628 assert!(chart.redo());
629 chart.drawings_mut().edit_drawing(7, |d| d.locked = true);
630 props.font_size = 18.0;
631 props.apply_to_chart(DrawingId(7), &mut chart).unwrap();
632 assert_eq!(
633 chart.drawings().drawings[0].font_size,
634 Drawing::new(7, tool).font_size
635 );
636 assert_eq!(chart.drawings().drawings[1].font_size, 18.0);
637 for (ratio, width) in [(f32::NAN, 2.0), (1.0, 0.0), (1.0, 100.01), (1.0, f32::NAN)] {
639 let config = props
640 .tool_settings
641 .as_mut()
642 .unwrap()
643 .settings
644 .fib_config
645 .as_mut()
646 .unwrap();
647 config.levels[0].value = ratio;
648 config.level_width = width;
649 props.line_width = 9.0;
650 assert!(props.apply_to_chart(DrawingId(7), &mut chart).is_err());
651 assert_eq!(
652 chart.drawings().drawings[0].stroke_width,
653 Drawing::new(7, tool).stroke_width
654 );
655 assert_eq!(
656 chart.drawings().drawings[1].stroke_width,
657 Drawing::new(8, DrawingToolType::TextLabel).stroke_width
658 );
659 }
660 }
661 }
662
663 #[test]
665 fn selection_preserves_wave_degrees_until_the_degree_is_edited() {
666 use crate::drawings::{DrawingToolType::*, ElliottDegree::*};
667 let mut chart = Chart::new(crate::model::BarData::new());
668 chart.drawings_mut().drawings = [
669 (ElliottImpulse, Minor),
670 (ElliottImpulse, Minute),
671 (ElliottCorrection, Cycle),
672 ]
673 .into_iter()
674 .enumerate()
675 .map(|(id, (tool, degree))| {
676 let mut drawing = Drawing::new(id, tool);
677 drawing.elliott_degree = degree;
678 drawing
679 })
680 .collect();
681 chart.drawings_mut().select_drawings([0, 1, 2]);
682 let mut props = DrawingProps::from_drawing(&chart.drawings().drawings[0], &chart);
683 props.color = Color32::GREEN;
685 props.apply_to_chart(DrawingId(0), &mut chart).unwrap();
686 assert_eq!(
687 chart
688 .drawings()
689 .drawings
690 .iter()
691 .map(|d| d.elliott_degree)
692 .collect::<Vec<_>>(),
693 [Minor, Minute, Cycle]
694 );
695 props
697 .tool_settings
698 .as_mut()
699 .unwrap()
700 .settings
701 .elliott_degree = Minuette;
702 props.apply_to_chart(DrawingId(0), &mut chart).unwrap();
703 assert_eq!(
704 chart
705 .drawings()
706 .drawings
707 .iter()
708 .map(|d| d.elliott_degree)
709 .collect::<Vec<_>>(),
710 [Minuette, Minuette, Cycle]
711 );
712 }
713
714 #[test]
718 fn selection_edits_change_only_the_edited_values() {
719 use crate::drawings::FontWeight;
720 let mut alpha = Drawing::new(1, DrawingToolType::TextLabel);
721 alpha.text = Some("Alpha".into());
722 let mut beta = Drawing::new(2, DrawingToolType::TextLabel);
723 beta.text = Some("Beta".into());
724 beta.color = [0, 128, 0, 255];
725 beta.font_weight = FontWeight::Bold;
726 beta.italic = true;
727 let mut line = Drawing::new(3, DrawingToolType::TrendLine);
728 line.text = Some("Breakout".into());
729 line.stroke_width = 3.0;
730 line.extend_right = true;
731 line.timeframe_visibility = TimeframeVisibility::specific(&["1h"]);
732 let sticker = Drawing::new(4, DrawingToolType::Sticker);
734 let drawings = vec![alpha, beta, line, sticker];
735 let mut chart = Chart::new(crate::model::BarData::new());
736 chart.drawings_mut().drawings = drawings.clone();
737 chart.drawings_mut().select_drawings([1, 2, 3, 4]);
738 let opened = DrawingProps::from_drawing(&drawings[0], &chart);
739 type Case = (&'static str, fn(&mut DrawingProps), fn(&mut Drawing));
741 let cases: [Case; 4] = [
742 ("nothing edited", |_| {}, |_| {}),
743 (
744 "colour",
745 |props| props.color = Color32::BLUE,
746 |d| d.color = [0, 0, 255, 255],
747 ),
748 (
749 "text",
750 |props| props.label = "Gamma".into(),
751 |d| {
752 if d.tool_type != DrawingToolType::Sticker {
753 d.text = Some("Gamma".into());
754 }
755 },
756 ),
757 (
758 "text cleared",
759 |props| props.label.clear(),
760 |d| {
761 if d.tool_type != DrawingToolType::Sticker {
762 d.text = None;
763 }
764 },
765 ),
766 ];
767 for (case, edit, change) in cases {
768 let mut props = opened.clone();
769 edit(&mut props);
770 let edits = props.edited_drawings(DrawingId(1), &chart).unwrap();
771 let mut expected = drawings.clone();
772 expected.iter_mut().for_each(change);
773 assert_eq!(format!("{edits:?}"), format!("{expected:?}"), "{case}");
774 }
775 for opacity in [None, Some(128)] {
777 let mut chart = Chart::new(crate::model::BarData::new());
778 for (id, size, opacity) in [(1, [240.0, 120.0], 255), (2, [120.0, 240.0], 64)] {
779 let mut picture = Drawing::new(id, DrawingToolType::Image);
780 picture.image = Some(crate::drawings::ImageConfig {
781 size: Some(size),
782 opacity,
783 });
784 chart.drawings_mut().drawings.push(picture);
785 }
786 chart.drawings_mut().select_drawings([1, 2]);
787 let mut props = DrawingProps::from_drawing(&chart.drawings().drawings[0], &chart);
788 chart.drawings_mut().drawings[0]
790 .image
791 .as_mut()
792 .unwrap()
793 .size = Some([480.0, 240.0]);
794 props.color = Color32::BLUE;
795 if let Some(opacity) = opacity {
796 props
797 .tool_settings
798 .as_mut()
799 .unwrap()
800 .settings
801 .image
802 .as_mut()
803 .unwrap()
804 .opacity = opacity;
805 }
806 let edits = props.edited_drawings(DrawingId(1), &chart).unwrap();
807 for (drawing, size, original_opacity) in [
808 (&edits[0], [480.0, 240.0], 255),
809 (&edits[1], [120.0, 240.0], 64),
810 ] {
811 let config = drawing.image.unwrap();
812 assert_eq!(config.size, Some(size));
813 assert_eq!(config.opacity, opacity.unwrap_or(original_opacity));
814 }
815 }
816 }
817}