1use 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
20pub struct DrawingPropertiesDialog {
22 pub config: DrawingPropertiesConfig,
24 pub is_open: bool,
26 pub drawing_id: Option<DrawingId>,
28 pub drawing_name: String,
30 pub active_tab: PropertiesTab,
32 pub props: DrawingProps,
34 pub coordinates: CoordinatesState,
36 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 pub error: Option<String>,
44}
45
46impl Default for DrawingPropertiesDialog {
47 fn default() -> Self {
48 Self::new()
49 }
50}
51
52impl DrawingPropertiesDialog {
53 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 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 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 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 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 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 pub fn close(&mut self) {
268 self.is_open = false;
269 self.drawing_id = None;
270 }
271
272 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 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 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 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 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 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
560const FOOTER_HEIGHT: f32 = 60.0;
563
564#[derive(Clone, Default)]
566struct UiState {
567 pickers: [ColorPickerState; 2],
569 text_pickers: [ColorPickerState; 3],
571 level_pickers: Vec<Vec<ColorPickerState>>,
573 had_focus: bool,
575}
576
577struct 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 #[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 #[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 #[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 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 #[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}