Skip to main content

egui_charts/ui/dialogs/drawing_properties/
dialog.rs

1//! Drawing properties dialog - modal popup for editing drawing properties.
2
3use crate::localization as l10n;
4use egui::{Context, Event, Key, Modifiers, Ui, Vec2};
5
6use super::actions::{DrawingId, DrawingPropertiesAction, DrawingProps};
7use super::config::DrawingPropertiesConfig;
8use super::tabs::{
9    CoordinatesState, CoordinatesTab, InputsTab, PropertiesTab, StyleTab, TextTab, VisibilityTab,
10};
11use crate::drawings::{Drawing, DrawingTemplate};
12use crate::services::StoreKind;
13use crate::ui::dialogs::templates::TemplateStore;
14use crate::ui::drawing_toolbar::{DrawingToolbarAction, utilities::TemplatesButton};
15use crate::ui_kit::ColorPickerState;
16use crate::ui_kit::buttons::Button;
17use crate::ui_kit::dialog::{DialogFooter, DialogFrame, dialog_header};
18use crate::ui_kit::tab_bar::TabBar;
19
20/// Drawing properties dialog
21pub struct DrawingPropertiesDialog {
22    /// Display and layout options for this component.
23    pub config: DrawingPropertiesConfig,
24    /// Whether the dialog is currently visible.
25    pub is_open: bool,
26    /// Identifier of the drawing being edited, when open.
27    pub drawing_id: Option<DrawingId>,
28    /// Display name of the drawing being edited.
29    pub drawing_name: String,
30    /// Currently selected tab.
31    pub active_tab: PropertiesTab,
32    /// Editable drawing appearance and tool settings.
33    pub props: DrawingProps,
34    /// Editable drawing anchors.
35    pub coordinates: CoordinatesState,
36    /// Named templates, kept in memory unless the host attaches its storage service.
37    pub templates: TemplateStore<DrawingTemplate>,
38    preview: Option<(String, DrawingId, Vec<PreviewDrawing>)>,
39    template_name: String,
40    saving_template: bool,
41    default_template: Option<DrawingTemplate>,
42    /// Validation error from the most recent edit.
43    pub error: Option<String>,
44}
45
46impl Default for DrawingPropertiesDialog {
47    fn default() -> Self {
48        Self::new()
49    }
50}
51
52impl DrawingPropertiesDialog {
53    /// Create a [`DrawingPropertiesDialog`] with default settings.
54    pub fn new() -> Self {
55        Self {
56            config: DrawingPropertiesConfig::default(),
57            is_open: false,
58            drawing_id: None,
59            drawing_name: String::new(),
60            active_tab: PropertiesTab::Style,
61            props: DrawingProps::default(),
62            coordinates: CoordinatesState::default(),
63            error: None,
64            templates: TemplateStore::new(StoreKind::DrawingTemplates),
65            preview: None,
66            template_name: String::new(),
67            saving_template: false,
68            default_template: None,
69        }
70    }
71
72    /// Open the dialog for a specific drawing
73    pub fn open(&mut self, id: DrawingId, name: String, props: DrawingProps) {
74        self.is_open = true;
75        self.drawing_id = Some(id);
76        self.drawing_name = name;
77        self.coordinates = props.coordinates.clone();
78        self.error = None;
79        self.template_name.clear();
80        self.saving_template = false;
81        self.default_template = props.tool_settings.as_ref().map(|template| {
82            DrawingTemplate::from_drawing(&Drawing::new(0, template.settings.tool_type))
83        });
84        self.props = props;
85        self.active_tab = PropertiesTab::Style;
86    }
87
88    /// Open with the selected drawing's saved properties and all of its coordinates.
89    pub fn open_for_chart(&mut self, chart: &crate::widget::Chart, id: DrawingId) -> bool {
90        let Some(drawing) = chart.drawings().drawings.iter().find(|d| d.id == id.0) else {
91            return false;
92        };
93        self.open(
94            id,
95            drawing.tool_type.as_str().into(),
96            DrawingProps::from_drawing(drawing, chart),
97        );
98        if let Some(defaults) = chart.drawings().tool_defaults.get(&drawing.tool_type) {
99            self.default_template = Some(defaults.clone());
100        }
101        true
102    }
103
104    /// Preview validated edits while open; Ok commits one undoable edit and Cancel restores
105    /// the original properties. Apply is a notification the host must not apply a second time.
106    /// Call every frame, including after closing externally, to restore an unfinished preview.
107    pub fn show_and_apply(
108        &mut self,
109        ctx: &Context,
110        chart: &mut crate::widget::Chart,
111    ) -> DrawingPropertiesAction {
112        let action = self.show(ctx);
113        // Each frame edits the drawings as they were before the preview, as Ok does.
114        self.restore_preview(chart);
115        if let DrawingPropertiesAction::Apply(id, props) = &action {
116            if let Err(error) = props.apply_to_chart(*id, chart) {
117                self.error = Some(error.into());
118                self.is_open = true;
119                return DrawingPropertiesAction::None;
120            }
121        } else if self.is_open
122            && let Some(id) = self.drawing_id
123        {
124            let mut props = self.props.clone();
125            props.coordinates = self.coordinates.clone();
126            match props.edited_drawings(id, chart) {
127                Ok(edits) => {
128                    if self.props.save_as_default
129                        && let Some(mut template) = self.current_template()
130                    {
131                        template.settings.visible = true;
132                        chart
133                            .drawings_mut()
134                            .tool_defaults
135                            .insert(template.settings.tool_type, template.clone());
136                        self.default_template = Some(template);
137                        self.props.save_as_default = false;
138                    }
139                    let changed = chart
140                        .drawings()
141                        .drawings
142                        .iter()
143                        .find(|d| d.id == id.0)
144                        .is_some_and(|current| {
145                            serde_json::to_value(DrawingTemplate::from_drawing(current)).ok()
146                                != self
147                                    .current_template()
148                                    .and_then(|template| serde_json::to_value(template).ok())
149                        })
150                        || !props
151                            .coordinates
152                            .resolve(chart)
153                            .unwrap_or_default()
154                            .is_empty();
155                    if !changed {
156                        self.error = None;
157                        return action;
158                    }
159                    let (_, _, originals) = self
160                        .preview
161                        .get_or_insert_with(|| (chart.symbol().to_owned(), id, Vec::new()));
162                    for edit in &edits {
163                        if !originals
164                            .iter()
165                            .any(|original| original.drawing.id == edit.id)
166                            && let Some(drawing) =
167                                chart.drawings().drawings.iter().find(|d| d.id == edit.id)
168                        {
169                            originals.push(PreviewDrawing {
170                                drawing: drawing.clone(),
171                                times: drawing
172                                    .chart_points
173                                    .iter()
174                                    .map(|p| chart.drawing_point_time(p))
175                                    .collect(),
176                            });
177                        }
178                    }
179                    for edit in edits {
180                        if let Some(drawing) = chart
181                            .drawings_mut()
182                            .drawings
183                            .iter_mut()
184                            .find(|d| d.id == edit.id)
185                        {
186                            *drawing = edit;
187                        }
188                    }
189                    self.error = None;
190                }
191                Err(error) => self.error = Some(error.into()),
192            }
193        }
194        action
195    }
196
197    fn restore_preview(&mut self, chart: &mut crate::widget::Chart) {
198        let Some((symbol, _, originals)) = self.preview.take() else {
199            return;
200        };
201        if symbol != chart.symbol() {
202            return;
203        }
204        for original in originals {
205            let points: Vec<_> = original
206                .drawing
207                .chart_points
208                .iter()
209                .zip(&original.times)
210                .map(|(point, time)| {
211                    time.and_then(|time| chart.drawing_point_at(time, point.price))
212                        .filter(|mapped| mapped.bar_idx != point.bar_idx)
213                        .unwrap_or(*point)
214                })
215                .collect();
216            if let Some(drawing) = chart.drawings_mut().drawings.iter_mut().find(|d| {
217                d.id == original.drawing.id
218                    && d.tool_type == original.drawing.tool_type
219                    && d.stored_id == original.drawing.stored_id
220            }) {
221                let old = original.drawing;
222                drawing.color = old.color;
223                drawing.stroke_width = old.stroke_width;
224                drawing.line_style = old.line_style;
225                drawing.fill_color = old.fill_color;
226                drawing.extend_left = old.extend_left;
227                drawing.extend_right = old.extend_right;
228                drawing.text = old.text;
229                drawing.font_size = old.font_size;
230                drawing.timeframe_visibility = old.timeframe_visibility;
231                drawing.fib_config = old.fib_config;
232                drawing.pitchfork = old.pitchfork;
233                drawing.gann = old.gann;
234                drawing.ghost_feed = old.ghost_feed;
235                drawing.bars_pattern = old.bars_pattern;
236                // Only the opacity is previewed; a corner drag may have resized the frame since.
237                if let Some(image) = &mut drawing.image {
238                    image.opacity = old.image.map_or(255, |c| c.opacity);
239                }
240                drawing.position_size = old.position_size;
241                drawing.position_sizing = old.position_sizing;
242                drawing.position_target_color = old.position_target_color;
243                drawing.position_stop_color = old.position_stop_color;
244                drawing.volume_profile = old.volume_profile;
245                drawing.regression_trend = old.regression_trend;
246                drawing.vwap = old.vwap;
247                drawing.channel_midline = old.channel_midline;
248                drawing.price_axis_label = old.price_axis_label;
249                drawing.time_axis_label = old.time_axis_label;
250                drawing.elliott_degree = old.elliott_degree;
251                drawing.font_weight = old.font_weight;
252                drawing.italic = old.italic;
253                drawing.text_color = old.text_color;
254                drawing.background = old.background;
255                drawing.border = old.border;
256                drawing.border_color = old.border_color;
257                drawing.arrow_start_style = old.arrow_start_style;
258                drawing.arrow_end_style = old.arrow_end_style;
259                drawing.custom_settings = old.custom_settings;
260                drawing.chart_points = points;
261                drawing.screen_anchor = old.screen_anchor;
262            }
263        }
264    }
265
266    /// Close the dialog
267    pub fn close(&mut self) {
268        self.is_open = false;
269        self.drawing_id = None;
270    }
271
272    /// Sync dialog open/close state from an external flag.
273    ///
274    /// Opening requires drawing context, so only close is synced here.
275    pub fn sync_open_state(&mut self, should_be_open: bool) {
276        if !should_be_open && self.is_open {
277            self.close();
278        }
279    }
280
281    /// Show the dialog
282    pub fn show(&mut self, ctx: &Context) -> DrawingPropertiesAction {
283        self.templates.poll(ctx);
284        if !self.is_open {
285            return DrawingPropertiesAction::None;
286        }
287
288        let mut action = DrawingPropertiesAction::None;
289
290        DialogFrame::new(
291            "Drawing Properties",
292            Vec2::new(self.config.width, self.config.height),
293        )
294        .show(ctx, |ui| {
295            action = self.render_contents(ui);
296        });
297
298        action
299    }
300
301    fn current_template(&self) -> Option<DrawingTemplate> {
302        let mut template = self.props.tool_settings.as_deref()?.clone();
303        let d = &mut template.settings;
304        d.color = self.props.color.to_srgba_unmultiplied();
305        d.stroke_width = self.props.line_width;
306        d.line_style = self.props.line_style;
307        d.fill_color = self.props.fill_enabled.then(|| {
308            self.props
309                .fill_color
310                .unwrap_or(self.props.color.gamma_multiply(0.2))
311                .to_srgba_unmultiplied()
312        });
313        d.extend_left = self.props.extend_left;
314        d.extend_right = self.props.extend_right;
315        d.text = (!self.props.label.is_empty()).then(|| self.props.label.clone());
316        d.font_size = self.props.font_size;
317        d.timeframe_visibility = self.props.timeframe_visibility.clone();
318        Some(template)
319    }
320
321    fn load_template(&mut self, template: DrawingTemplate) {
322        if self.props.tool_settings.as_ref().is_none_or(|current| {
323            current.settings.tool_type != template.settings.tool_type
324                || current.settings.custom_tool != template.settings.custom_tool
325        }) {
326            return;
327        }
328        let d = &template.settings;
329        self.props.color = d.color32();
330        self.props.line_width = d.stroke_width;
331        self.props.line_style = d.line_style;
332        self.props.fill_color = d.fill_color32();
333        self.props.fill_enabled = d.fill_color.is_some();
334        self.props.extend_left = d.extend_left;
335        self.props.extend_right = d.extend_right;
336        self.props.label = d.text.clone().unwrap_or_default();
337        self.props.font_size = d.font_size;
338        self.props.timeframe_visibility = d.timeframe_visibility.clone();
339        self.props.tool_settings = Some(Box::new(template));
340    }
341
342    fn template_menu(&mut self, ui: &mut Ui) {
343        let mut load = None;
344        let menu = Button::new("Template").ghost().show(ui);
345        egui::Popup::menu(&menu).show(|ui| {
346            if ui
347                .button(l10n::text(ui.ctx(), "Save as template…"))
348                .clicked()
349            {
350                self.saving_template = true;
351                ui.close();
352            }
353            if ui
354                .button(l10n::text(ui.ctx(), "Save as default for this tool"))
355                .clicked()
356            {
357                self.props.save_as_default = true;
358                ui.close();
359            }
360            if ui
361                .button(l10n::text(ui.ctx(), "Reset to defaults"))
362                .clicked()
363            {
364                load = self.default_template.clone();
365                ui.close();
366            }
367            ui.separator();
368            let mut delete = None;
369            if let Some(current) = &self.props.tool_settings {
370                for (name, template) in self.templates.entries() {
371                    if template.settings.tool_type != current.settings.tool_type
372                        || template.settings.custom_tool != current.settings.custom_tool
373                    {
374                        continue;
375                    }
376                    if ui.button(name).clicked() {
377                        load = Some(template.clone());
378                        ui.close();
379                    }
380                    if ui
381                        .button(l10n::format_text(
382                            ui.ctx(),
383                            "Delete {name}",
384                            &[("name", name.clone())],
385                        ))
386                        .clicked()
387                    {
388                        delete = Some(name.clone());
389                    }
390                }
391            }
392            if let Some(name) = delete
393                && let Err(error) = self.templates.delete(&name)
394            {
395                self.templates.error = Some(error);
396            }
397        });
398        if let Some(template) = load {
399            self.load_template(template);
400        }
401        if self.saving_template
402            && let Some(action) =
403                TemplatesButton::show_name_dialog(ui, ui.min_rect(), &mut self.template_name)
404        {
405            match action {
406                DrawingToolbarAction::SaveTemplate { name } => {
407                    if let Some(template) = self.current_template() {
408                        let saved = self
409                            .props
410                            .validate()
411                            .map_err(str::to_owned)
412                            .and_then(|()| self.templates.save(&name, template));
413                        match saved {
414                            Ok(()) => {
415                                self.saving_template = false;
416                                self.template_name.clear();
417                            }
418                            Err(error) => self.templates.error = Some(error),
419                        }
420                    }
421                }
422                _ => self.saving_template = false,
423            }
424        }
425        if let Some(error) = &self.templates.error {
426            ui.colored_label(ui.visuals().error_fg_color, l10n::text(ui.ctx(), error));
427        }
428    }
429
430    fn render_contents(&mut self, ui: &mut Ui) -> DrawingPropertiesAction {
431        let ctx = ui.ctx().clone();
432        // Read before the body: a combo popup closes itself on the Escape it receives there.
433        let popup_open = egui::Popup::is_any_open(&ctx);
434        let state_id = ui.scope_id().with("keyboard_and_pickers");
435        let mut state: UiState = ui.data(|d| d.get_temp(state_id)).unwrap_or_default();
436
437        let mut cancel = dialog_header(ui, &self.drawing_name);
438        let has_inputs = self
439            .props
440            .tool_settings
441            .as_ref()
442            .is_some_and(|t| InputsTab::available(t.settings.tool_type));
443        let tabs: Vec<_> = PropertiesTab::all()
444            .into_iter()
445            .filter(|t| match t {
446                PropertiesTab::Inputs => has_inputs,
447                PropertiesTab::Text => self
448                    .props
449                    .tool_settings
450                    .as_ref()
451                    .is_some_and(|t| t.settings.tool_type.supports_text()),
452                PropertiesTab::Coordinates => self
453                    .props
454                    .tool_settings
455                    .as_ref()
456                    .is_none_or(|t| CoordinatesTab::available(t.settings.tool_type)),
457                _ => true,
458            })
459            .collect();
460        if !tabs.contains(&self.active_tab) {
461            self.active_tab = PropertiesTab::Style;
462        }
463        egui::ScrollArea::horizontal()
464            .id_salt("properties_tabs")
465            .show(ui, |ui| TabBar::new(&tabs, &mut self.active_tab).show(ui));
466
467        // Vertical auto-shrink stays on: a full-height body would push the pinned footer out.
468        egui::ScrollArea::vertical()
469            .auto_shrink([false, true])
470            .max_height(
471                (ui.available_height()
472                    - FOOTER_HEIGHT
473                    - if self.error.is_some() { 40.0 } else { 0.0 })
474                .max(0.0),
475            )
476            .show(ui, |ui| match self.active_tab {
477                PropertiesTab::Style => {
478                    StyleTab::show(ui, &mut self.props, &mut state.pickers);
479                }
480                PropertiesTab::Inputs => {
481                    let color = self.props.color.to_srgba_unmultiplied();
482                    if let Some(template) = &mut self.props.tool_settings {
483                        template.settings.color = color;
484                        InputsTab::show(
485                            ui,
486                            &mut template.settings,
487                            &mut state.level_pickers,
488                            &mut self.coordinates,
489                            self.props.tick_size,
490                        );
491                    }
492                }
493                PropertiesTab::Coordinates => CoordinatesTab::show(ui, &mut self.coordinates),
494                PropertiesTab::Visibility => VisibilityTab::show(ui, &mut self.props),
495                PropertiesTab::Text => TextTab::show(ui, &mut self.props, &mut state.text_pickers),
496            });
497
498        if let Some(error) = &self.error {
499            ui.colored_label(ui.visuals().error_fg_color, l10n::text(ui.ctx(), error));
500        }
501
502        let (mut apply, cancel_clicked) = DialogFooter::new("Ok")
503            .secondary("Cancel")
504            .show_with_left(ui, |ui| self.template_menu(ui));
505        cancel |= cancel_clicked;
506
507        // An open popup, colour picker or focused field takes Escape and Enter first. egui clears
508        // the focus on Escape before this frame runs, so the focus is read from the previous frame.
509        // Only a fresh press acts: the repeats of a key held after it closed a popup, picker or
510        // field must not go on to cancel or apply the dialog.
511        let consume = |key| {
512            ctx.input_mut(|i| {
513                let fresh = i.events.iter().any(|e| {
514                    matches!(e, Event::Key { key: k, pressed: true, repeat: false, modifiers, .. }
515                        if *k == key && modifiers.is_none())
516                });
517                fresh && i.consume_key(Modifiers::NONE, key)
518            })
519        };
520        if state
521            .pickers
522            .iter()
523            .chain(&state.text_pickers)
524            .chain(state.level_pickers.iter().flatten())
525            .any(|p| p.is_open)
526        {
527            if consume(Key::Escape) {
528                state
529                    .pickers
530                    .iter_mut()
531                    .chain(&mut state.text_pickers)
532                    .chain(state.level_pickers.iter_mut().flatten())
533                    .for_each(|p| p.is_open = false);
534            }
535        } else if !popup_open && !state.had_focus {
536            cancel |= consume(Key::Escape);
537            apply |= !cancel && !self.saving_template && consume(Key::Enter);
538        }
539        state.had_focus = ctx.memory(|m| m.focused()).is_some();
540
541        let action = match self.drawing_id {
542            Some(id) if apply => {
543                self.props.coordinates = self.coordinates.clone();
544                DrawingPropertiesAction::Apply(id, Box::new(self.props.clone()))
545            }
546            _ if cancel => DrawingPropertiesAction::Cancel,
547            _ => DrawingPropertiesAction::None,
548        };
549        // A closed dialog starts with fresh keyboard and picker state next time.
550        if matches!(action, DrawingPropertiesAction::None) {
551            ui.data_mut(|d| d.insert_temp(state_id, state));
552        } else {
553            self.is_open = false;
554            ui.data_mut(|d| d.remove::<UiState>(state_id));
555        }
556        action
557    }
558}
559
560/// Height of the `DialogFooter` block below the body: two `spacing.lg` gaps, the rule and a
561/// `ButtonSize::MD` row, with their item spacing.
562const FOOTER_HEIGHT: f32 = 60.0;
563
564/// Keyboard and colour picker state kept in egui memory between frames.
565#[derive(Clone, Default)]
566struct UiState {
567    /// Colour and fill colour pickers of the Style tab.
568    pickers: [ColorPickerState; 2],
569    /// Text colour, background and border pickers of the Text tab.
570    text_pickers: [ColorPickerState; 3],
571    /// One list of colour pickers per level grid of the current tool.
572    level_pickers: Vec<Vec<ColorPickerState>>,
573    /// Whether a widget had keyboard focus at the end of the previous frame.
574    had_focus: bool,
575}
576
577/// Only previewed drawings are restored; incoming host drawings and metadata stay intact.
578struct PreviewDrawing {
579    drawing: Drawing,
580    times: Vec<Option<chrono::DateTime<chrono::Utc>>>,
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586    use crate::drawings::{ChartPoint, DrawingToolType as Tool};
587    use crate::model::BarData;
588    use crate::widget::Chart;
589    use egui::{Color32, Pos2, Rect, Shape};
590
591    fn frame(
592        dialog: &mut DrawingPropertiesDialog,
593        chart: &mut Chart,
594        ctx: &Context,
595        events: Vec<Event>,
596    ) -> Vec<(String, Pos2)> {
597        let mut output = ctx.run_ui(
598            egui::RawInput {
599                screen_rect: Some(Rect::from_min_size(Pos2::ZERO, egui::vec2(1440.0, 900.0))),
600                events,
601                ..Default::default()
602            },
603            |ui| {
604                dialog.show_and_apply(ui.ctx(), chart);
605            },
606        );
607        let text = output
608            .shapes
609            .iter()
610            .filter_map(|s| match &s.shape {
611                Shape::Text(t) => Some((t.galley.text().to_owned(), t.pos + t.galley.size() / 2.0)),
612                _ => None,
613            })
614            .collect();
615        output.textures_delta.clear();
616        output.drop_without_applying_deltas();
617        text
618    }
619
620    fn click(dialog: &mut DrawingPropertiesDialog, chart: &mut Chart, ctx: &Context, label: &str) {
621        let texts = frame(dialog, chart, ctx, vec![]);
622        let pos = texts
623            .iter()
624            .find(|(text, _)| text == label)
625            .unwrap_or_else(|| panic!("Missing {label}: {texts:?}"))
626            .1;
627        frame(dialog, chart, ctx, vec![Event::PointerMoved(pos)]);
628        for pressed in [true, false] {
629            frame(
630                dialog,
631                chart,
632                ctx,
633                vec![Event::PointerButton {
634                    pos,
635                    button: egui::PointerButton::Primary,
636                    pressed,
637                    modifiers: Modifiers::NONE,
638                }],
639            );
640        }
641    }
642
643    /// Unsupported settings never appear, including when reusing the dialog after a text tool.
644    #[test]
645    fn properties_rows_follow_the_tool_renderer() {
646        for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
647            let ctx = Context::default();
648            crate::theme::apply_to_egui(&ctx, &theme);
649            let mut chart = Chart::new(BarData::new());
650            let mut dialog = DrawingPropertiesDialog::new();
651            for (tool, fill, extend, text) in [
652                (Tool::HorizontalLine, false, false, true),
653                (Tool::Rect, true, true, true),
654                (Tool::Brush, false, false, false),
655                (Tool::TrendLine, false, true, true),
656                (Tool::TextLabel, false, false, true),
657                (Tool::Highlighter, false, false, false),
658            ] {
659                let mut drawing = Drawing::new(7, tool);
660                drawing.chart_points = vec![ChartPoint::new(1.0, 100.0); 3];
661                chart.drawings_mut().drawings = vec![drawing];
662                dialog.open_for_chart(&chart, DrawingId(7));
663                let freehand = matches!(tool, Tool::Brush | Tool::Highlighter);
664                assert_eq!(
665                    dialog.coordinates.points.len(),
666                    if freehand { 0 } else { 3 },
667                    "{tool:?}: staged anchors"
668                );
669                frame(&mut dialog, &mut chart, &ctx, vec![]);
670                let labels = frame(&mut dialog, &mut chart, &ctx, vec![]);
671                for (label, present) in [
672                    ("Fill", fill),
673                    ("Extend", extend),
674                    ("Text", text),
675                    ("Coordinates", !freehand),
676                    ("Template", true),
677                    ("Ok", true),
678                    ("Delete", false),
679                    ("Clone", false),
680                ] {
681                    assert_eq!(
682                        labels.iter().any(|(text, _)| text == label),
683                        present,
684                        "{tool:?}: {label}"
685                    );
686                }
687                dialog.close();
688                frame(&mut dialog, &mut chart, &ctx, vec![]);
689            }
690        }
691    }
692
693    /// The Visibility tab writes only the unit row the user edits, so labels saved before
694    /// interval ranges keep their exact visibility.
695    #[test]
696    fn visibility_rows_keep_saved_interval_labels_until_edited() {
697        use crate::drawings::TimeframeVisibility;
698        use crate::model::TimeframeUnit;
699        let ctx = Context::default();
700        let mut chart = Chart::new(BarData::new());
701        let mut drawing = Drawing::new(7, Tool::TrendLine);
702        drawing.timeframe_visibility = TimeframeVisibility::specific(&["1h", "4h"]);
703        chart.drawings_mut().drawings = vec![drawing];
704        let mut dialog = DrawingPropertiesDialog::new();
705        dialog.open_for_chart(&chart, DrawingId(7));
706        dialog.active_tab = PropertiesTab::Visibility;
707        frame(&mut dialog, &mut chart, &ctx, vec![]);
708        frame(&mut dialog, &mut chart, &ctx, vec![]);
709        let mut want = TimeframeVisibility::specific(&["1h", "4h"]);
710        assert_eq!(chart.drawings().drawings[0].timeframe_visibility, want);
711        click(&mut dialog, &mut chart, &ctx, "Ticks");
712        want.ranges.insert(TimeframeUnit::Tick, 1..=1_000_000);
713        assert_eq!(chart.drawings().drawings[0].timeframe_visibility, want);
714    }
715
716    /// Preview edits leave both histories alone; accepting a sequence commits one bulk edit.
717    #[test]
718    fn preview_edits_commit_once_or_restore_without_history() {
719        for finish in ["Ok", "Cancel", "Escape", "close", "reopen"] {
720            let ctx = Context::default();
721            let mut chart = Chart::new(BarData::new());
722            for (id, width) in [(7, 20.0), (8, 4.0), (9, 6.0)] {
723                let mut drawing = Drawing::new(id, Tool::TrendLine);
724                drawing.stroke_width = width;
725                drawing.chart_points =
726                    vec![ChartPoint::new(2.0, 100.0), ChartPoint::new(5.0, 110.0)];
727                chart.drawings_mut().drawings.push(drawing);
728            }
729            chart.user_action(|chart| {
730                chart
731                    .drawings_mut()
732                    .edit_drawing(9, |d| d.stroke_width = 8.0)
733            });
734            assert!(chart.undo());
735            chart.drawings_mut().select_drawings([7, 8]);
736            let mut dialog = DrawingPropertiesDialog::new();
737            dialog.open_for_chart(&chart, DrawingId(7));
738            frame(&mut dialog, &mut chart, &ctx, vec![]);
739            assert_eq!(
740                chart.drawings().drawings[1].stroke_width,
741                4.0,
742                "opening a selection is not an edit"
743            );
744            assert_eq!(
745                chart.drawings().drawings[0].stroke_width,
746                20.0,
747                "opening keeps a wide stroke"
748            );
749            assert!(!chart.can_undo());
750            // At 20, drawing 7's own width, the width is not edited and 8 shows its own.
751            for (width, peer) in [(3.0, 3.0), (20.0, 4.0), (5.0, 5.0)] {
752                dialog.props.line_width = width;
753                dialog.props.color = Color32::GREEN;
754                let settings = &mut dialog.props.tool_settings.as_mut().unwrap().settings;
755                settings.position_target_color = Some([40, 100, 220, 255]);
756                settings.position_stop_color = Some([220, 140, 40, 255]);
757                settings.price_axis_label = false;
758                settings.time_axis_label = false;
759                settings.italic = true;
760                settings.text_color = Some([1, 2, 3, 255]);
761                dialog.coordinates.points[1].price = "125".into();
762                frame(&mut dialog, &mut chart, &ctx, vec![]);
763                assert_eq!(
764                    chart.drawings().drawings[0].stroke_width,
765                    width,
766                    "{finish}: live preview"
767                );
768                assert_eq!(chart.drawings().drawings[1].stroke_width, peer, "{finish}");
769                let preview = &chart.drawings().drawings[0];
770                assert_eq!(preview.position_target_color, Some([40, 100, 220, 255]));
771                assert_eq!(preview.position_stop_color, Some([220, 140, 40, 255]));
772                assert!(!preview.price_axis_label && !preview.time_axis_label);
773                assert!(preview.italic);
774                assert_eq!(preview.text_color, Some([1, 2, 3, 255]));
775                assert_eq!(chart.drawings().drawings[2].stroke_width, 6.0);
776                assert!(!chart.can_undo());
777                assert!(chart.can_redo(), "preview must retain existing redo");
778                assert!(!chart.drawings().can_undo());
779            }
780            match finish {
781                "Ok" | "Cancel" => click(&mut dialog, &mut chart, &ctx, finish),
782                "Escape" => {
783                    frame(
784                        &mut dialog,
785                        &mut chart,
786                        &ctx,
787                        vec![Event::Key {
788                            key: Key::Escape,
789                            physical_key: None,
790                            pressed: true,
791                            repeat: false,
792                            modifiers: Modifiers::NONE,
793                        }],
794                    );
795                }
796                _ => {
797                    if finish == "reopen" {
798                        dialog.open_for_chart(&chart, DrawingId(9));
799                        frame(&mut dialog, &mut chart, &ctx, vec![]);
800                        assert_eq!(chart.drawings().drawings[0].stroke_width, 20.0);
801                        assert_eq!(chart.drawings().drawings[1].stroke_width, 4.0);
802                    }
803                    dialog.close();
804                    frame(&mut dialog, &mut chart, &ctx, vec![]);
805                }
806            }
807            assert!(!dialog.is_open, "{finish}");
808            if finish == "Ok" {
809                assert_eq!(chart.drawings().drawings[0].stroke_width, 5.0);
810                assert_eq!(chart.drawings().drawings[0].chart_points[1].price, 125.0);
811                assert!(chart.undo());
812            }
813            let restored = &chart.drawings().drawings[0];
814            assert_eq!(restored.position_target_color, None, "{finish}");
815            assert_eq!(restored.position_stop_color, None, "{finish}");
816            assert!(
817                restored.price_axis_label && restored.time_axis_label,
818                "{finish}"
819            );
820            assert!(!restored.italic, "{finish}");
821            assert_eq!(restored.text_color, None, "{finish}");
822            assert_eq!(chart.drawings().drawings[0].stroke_width, 20.0, "{finish}");
823            assert_eq!(chart.drawings().drawings[1].stroke_width, 4.0, "{finish}");
824            assert_eq!(
825                chart.drawings().drawings[0].chart_points[1].price,
826                110.0,
827                "{finish}"
828            );
829            assert!(!chart.can_undo(), "{finish}: exactly one accepted step");
830            if finish == "Ok" {
831                assert!(chart.redo());
832                assert_eq!(chart.drawings().drawings[0].stroke_width, 5.0);
833            } else {
834                assert!(chart.redo(), "{finish}: cancellation retains existing redo");
835                assert_eq!(chart.drawings().drawings[2].stroke_width, 8.0);
836                assert_eq!(chart.drawings().drawings[0].stroke_width, 20.0);
837            }
838        }
839    }
840
841    /// Templates act on this tool's style, remain usable after cancelling a preview and can be
842    /// deleted from the menu.
843    /// A previewed image opacity is restored by Cancel and committed by Ok as one undo step,
844    /// without touching a frame resized while the dialog is open.
845    #[test]
846    fn image_opacity_preview_restores_or_commits_once() {
847        for finish in ["Ok", "Cancel"] {
848            let ctx = Context::default();
849            let mut chart = Chart::new(BarData::new());
850            let mut picture = Drawing::new(7, Tool::Image);
851            picture.chart_points = vec![ChartPoint::new(2.0, 100.0)];
852            chart.drawings_mut().drawings.push(picture);
853            let mut dialog = DrawingPropertiesDialog::new();
854            dialog.open_for_chart(&chart, DrawingId(7));
855            frame(&mut dialog, &mut chart, &ctx, vec![]);
856            chart.drawings_mut().drawings[0]
857                .image
858                .get_or_insert_with(Default::default)
859                .size = Some([200.0, 100.0]);
860            let settings = &mut dialog.props.tool_settings.as_mut().unwrap().settings;
861            settings.image.get_or_insert_with(Default::default).opacity = 64;
862            frame(&mut dialog, &mut chart, &ctx, vec![]);
863            frame(&mut dialog, &mut chart, &ctx, vec![]);
864            assert_eq!(chart.drawings().drawings[0].image.unwrap().opacity, 64);
865            click(&mut dialog, &mut chart, &ctx, finish);
866            let image = chart.drawings().drawings[0].image.unwrap();
867            assert_eq!(image.size, Some([200.0, 100.0]), "{finish}");
868            if finish == "Ok" {
869                assert_eq!(image.opacity, 64);
870                assert!(chart.undo());
871            }
872            assert_eq!(
873                chart.drawings().drawings[0]
874                    .image
875                    .map_or(255, |c| c.opacity),
876                255,
877                "{finish}"
878            );
879            assert!(!chart.can_undo(), "{finish}");
880        }
881    }
882
883    #[test]
884    fn footer_templates_apply_save_defaults_and_reset() {
885        let ctx = Context::default();
886        let mut chart = Chart::new(BarData::new());
887        let mut drawing = Drawing::new(7, Tool::TrendLine);
888        drawing.arrow_end_style = crate::drawings::ArrowStyle::Circle;
889        chart.drawings_mut().drawings.push(drawing);
890        let mut dialog = DrawingPropertiesDialog::new();
891        dialog.open_for_chart(&chart, DrawingId(7));
892        frame(&mut dialog, &mut chart, &ctx, vec![]);
893        dialog.props.line_width = 7.0;
894        click(&mut dialog, &mut chart, &ctx, "Template");
895        click(&mut dialog, &mut chart, &ctx, "Save as template…");
896        frame(
897            &mut dialog,
898            &mut chart,
899            &ctx,
900            vec![Event::Text("Thick line".into())],
901        );
902        click(&mut dialog, &mut chart, &ctx, "Save");
903        click(&mut dialog, &mut chart, &ctx, "Template");
904        click(&mut dialog, &mut chart, &ctx, "Reset to defaults");
905        click(&mut dialog, &mut chart, &ctx, "Template");
906        click(&mut dialog, &mut chart, &ctx, "Thick line");
907        assert_eq!(chart.drawings().drawings[0].stroke_width, 7.0);
908        assert_eq!(
909            chart.drawings().drawings[0].arrow_end_style,
910            crate::drawings::ArrowStyle::Circle
911        );
912        click(&mut dialog, &mut chart, &ctx, "Template");
913        click(&mut dialog, &mut chart, &ctx, "Reset to defaults");
914        assert_eq!(chart.drawings().drawings[0].stroke_width, 2.0);
915        dialog.props.line_width = 4.0;
916        click(&mut dialog, &mut chart, &ctx, "Template");
917        click(
918            &mut dialog,
919            &mut chart,
920            &ctx,
921            "Save as default for this tool",
922        );
923        assert_eq!(
924            chart.drawings().tool_defaults[&Tool::TrendLine]
925                .settings
926                .stroke_width,
927            4.0
928        );
929        click(&mut dialog, &mut chart, &ctx, "Ok");
930        assert_eq!(
931            chart.drawings().tool_defaults[&Tool::TrendLine]
932                .settings
933                .stroke_width,
934            4.0
935        );
936        assert!(chart.undo());
937        assert_eq!(chart.drawings().drawings[0].stroke_width, 2.0);
938        chart.drawings_mut().drawings[0].tool_type = Tool::Rect;
939        dialog.open_for_chart(&chart, DrawingId(7));
940        frame(&mut dialog, &mut chart, &ctx, vec![]);
941        click(&mut dialog, &mut chart, &ctx, "Template");
942        let labels = frame(&mut dialog, &mut chart, &ctx, vec![]);
943        assert!(!labels.iter().any(|(text, _)| text == "Thick line"));
944        dialog.close();
945        frame(&mut dialog, &mut chart, &ctx, vec![]);
946        chart.drawings_mut().drawings[0].tool_type = Tool::TrendLine;
947        dialog.open_for_chart(&chart, DrawingId(7));
948        click(&mut dialog, &mut chart, &ctx, "Template");
949        click(&mut dialog, &mut chart, &ctx, "Delete Thick line");
950        assert!(dialog.templates.entries().is_empty());
951    }
952}