Skip to main content

egui_charts/ui/dialogs/drawing_properties/
actions.rs

1//! Actions from the drawing properties dialog.
2
3use 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/// Unique identifier for a drawing
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
14pub struct DrawingId(pub usize);
15
16/// Drawing properties that can be edited
17#[derive(Clone, Debug)]
18pub struct DrawingProps {
19    /// Symbol whose drawing was opened; absent for manually assembled properties.
20    pub symbol: Option<String>,
21    /// Tool-specific settings, edited by the Inputs page.
22    pub tool_settings: Option<Box<DrawingTemplate>>,
23    /// Instrument tick size for position level inputs; absent disables editing by ticks.
24    pub tick_size: Option<f64>,
25    /// Staged anchor edits, applied only to the drawing whose dialog was opened.
26    pub coordinates: CoordinatesState,
27    /// Save these properties as the default for new drawings of this tool.
28    pub save_as_default: bool,
29    /// Primary color
30    pub color: Color32,
31    /// Line width (1-10)
32    pub line_width: f32,
33    /// Line style
34    pub line_style: LineStyle,
35    /// Fill color (for shapes)
36    pub fill_color: Option<Color32>,
37    /// Fill enabled
38    pub fill_enabled: bool,
39    /// Extend line left
40    pub extend_left: bool,
41    /// Extend line right
42    pub extend_right: bool,
43    /// Text label
44    pub label: String,
45    /// Font size
46    pub font_size: f32,
47    /// The intervals the drawing shows on
48    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/// Actions from the drawing properties dialog
74#[derive(Clone, Debug)]
75#[non_exhaustive]
76pub enum DrawingPropertiesAction {
77    /// No action requested.
78    None,
79    /// Cancel the current interaction.
80    Cancel,
81    /// Apply the supplied properties to the drawing.
82    Apply(DrawingId, Box<DrawingProps>),
83    /// Delete the identified drawing.
84    Delete(DrawingId),
85    /// Duplicate the identified drawing.
86    Clone(DrawingId),
87}
88
89impl DrawingProps {
90    /// Read every editable property without changing the drawing or its selection.
91    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    /// Validate and apply one undoable edit to the unlocked selection. A value that differs from
114    /// drawing `id`'s, which these properties were read from, was edited, and only edited values
115    /// change the drawings: text only those that show text, tool inputs only drawings of the same
116    /// tool, coordinates only `id`. Errors leave all live drawings unchanged.
117    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    /// Prepare validated edits without history or persistence side effects, for live preview.
142    /// Values compare with drawing `id` as `chart` holds it, so a preview restores its drawings
143    /// before calling this.
144    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            // The dialog opened with the target's values: one that differs from them was edited,
203            // and only edited values are written, so each drawing keeps the rest of its own.
204            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            // A tool that shows no text keeps it: it holds a sticker, an image or an icon.
220            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                // A tool setting that differs from the target's was edited.
240                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                // Only the image's opacity: each picture keeps its own frame size.
275                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    /// Existing drawing settings survive opening and applying the properties, bulk edits over the
384    /// selection stay atomic, and every tool's saved settings reach the drawing.
385    #[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        // A size the drawings file would refuse cannot be applied.
424        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            // A boxed label, so that the settings below would show on it if they leaked.
494            (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            // Malformed ratios or widths cannot partly change another selected drawing.
638            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    /// Editing another property keeps each count's degree; editing the degree reaches matching tools.
664    #[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        // A colour edit keeps the degrees of the other selected counts.
684        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        // A degree edit reaches the other impulse, leaving the correction's degree alone.
696        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    /// Confirming the dialog of one drawing in a selection changes the others only where it was
715    /// edited: each keeps its own text, width, extension, visibility and tool settings, and an
716    /// edited text reaches only the drawings that show text.
717    #[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        // A sticker keeps its kind in its text, which it does not show.
733        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        // Each case: the edit made in the dialog, then what it changes on each drawing.
740        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        // An image's opacity is independent of its frame and of the other selected pictures.
776        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            // The picture was resized after the dialog opened.
789            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}