1use crate::drawings::domain::{ChartPoint, Drawing, DrawingToolType, HandlePos};
8use crate::drawings::services::{
9 HandleConfig, HistoryService, SelectionService, SnapOptions, SnapService,
10};
11use crate::tokens::DESIGN_TOKENS;
12use egui::epaint::ClippedShape;
13use egui::{Pos2, Rect};
14use std::sync::{Arc, Mutex};
15
16mod edit;
17mod interaction;
18mod paint;
19
20pub(crate) use paint::DrawingAxisTag;
21use paint::Tessellated;
22
23#[derive(Debug, Clone)]
35#[non_exhaustive]
36pub enum DrawingSyncAction {
37 SaveDrawing(usize),
39 UpdateDrawing(usize),
41 Delete(String),
43 RestoreDrawings(Vec<crate::drawings::persistence::StoredDrawing>),
46}
47
48#[derive(Debug, Clone, Default)]
54pub struct DrawingState {
55 pub active_tool: Option<DrawingToolType>,
57 pub sel_drawing: Option<usize>,
59 pub magnet_mode: bool,
61 pub stay_in_drawing_mode: bool,
63 pub curr_color: Option<[u8; 4]>,
66 pub eraser_mode: bool,
68}
69
70#[derive(Clone, Debug)]
75pub struct DrawingManagerOptions {
76 pub default_color: Option<[u8; 4]>,
79 pub snap_to_price: bool,
81 pub snap_to_time: bool,
83 pub snap_distance: f32,
85 pub magnet_mode: bool,
87 pub magnet_distance: f32,
89 pub strong_magnet: bool,
92 pub snap_to_indicators: bool,
94 pub handle_config: HandleConfig,
96}
97
98impl Default for DrawingManagerOptions {
99 fn default() -> Self {
100 Self {
101 default_color: None,
102 snap_to_price: true,
103 snap_to_time: true,
104 snap_distance: DESIGN_TOKENS.spacing.lg + DESIGN_TOKENS.spacing.xs,
105 magnet_mode: false,
106 magnet_distance: DESIGN_TOKENS.sizing.drawing.magnet_distance,
107 strong_magnet: false,
108 snap_to_indicators: true,
109 handle_config: HandleConfig::default(),
110 }
111 }
112}
113
114struct PaintedDrawing {
116 id: usize,
117 points: Vec<Pos2>,
118 shapes: Vec<ClippedShape>,
119 regression_ends: Option<[Pos2; 2]>,
121 tessellated: Option<Tessellated>,
123}
124
125pub(crate) struct TextEditState {
127 pub id: usize,
128 pub draft: String,
129 pub focus: bool,
130 pub opened_frame: Option<u64>,
131 pub placed: bool,
133 pub inline: bool,
135}
136
137impl TextEditState {
138 pub(crate) fn shown_text(&self, ctx: &egui::Context) -> String {
140 if self.draft.is_empty() {
141 crate::localization::text(ctx, "Add text")
142 } else {
143 self.draft.clone()
144 }
145 }
146
147 pub(crate) fn preview(&self, drawing: &Drawing, ctx: &egui::Context) -> Drawing {
149 let mut shown = drawing.clone();
150 if self.draft.is_empty() {
151 shown.text_color = Some(
152 ctx.global_style()
153 .visuals
154 .weak_text_color()
155 .to_srgba_unmultiplied(),
156 );
157 }
158 shown.text = Some(self.shown_text(ctx));
159 shown
160 }
161}
162
163pub struct DrawingManager {
204 pub registry: super::DrawingToolRegistry,
206 active_custom_tool: Option<String>,
207 pub tool_defaults: std::collections::HashMap<DrawingToolType, super::DrawingTemplate>,
210 pub drawings: Vec<Drawing>,
212 pub(crate) active_tool: Option<DrawingToolType>,
214 pub(crate) curr_drawing: Option<Drawing>,
216 preview_point: bool,
218 pub(crate) measurement: Option<Drawing>,
221 next_id: usize,
223
224 pane_rect: Rect,
226 pane: Option<usize>,
227 pane_members: Vec<Option<usize>>,
228 pane_clips: std::collections::HashMap<Option<usize>, Rect>,
229 pub(crate) text_editor: Option<TextEditState>,
230 pub(crate) finished_text_placement: Option<usize>,
232 pub(crate) image_request: Option<usize>,
234
235 selection: SelectionService,
237 history: HistoryService,
238 snap_service: SnapService,
239 painted: Mutex<Vec<PaintedDrawing>>,
241 pub(crate) snap_targets: Vec<Pos2>,
243
244 pub options: DrawingManagerOptions,
246
247 pub(crate) dragging_handle: Option<(usize, HandlePos)>,
249 drag_old_state: Option<Drawing>,
251 drag_origin: Option<Pos2>,
253 drag_group: Vec<Drawing>,
255 drag_copy: bool,
257 pub(crate) interaction_modifiers: egui::Modifiers,
259 pub(crate) place_at_pointer: bool,
261
262 pub stay_in_drawing_mode: bool,
265 pub curr_timeframe: String,
269 pub(crate) eraser_mode: bool,
272
273 pub pending_sync: Arc<Mutex<Vec<DrawingSyncAction>>>,
277 pub(crate) sync_enabled: bool,
279}
280
281impl Default for DrawingManager {
282 fn default() -> Self {
283 Self::new()
284 }
285}
286
287impl DrawingManager {
288 pub fn set_sync_enabled(&mut self, enabled: bool) {
292 self.sync_enabled = enabled;
293 if !enabled {
294 self.pending_sync
295 .lock()
296 .unwrap_or_else(|error| error.into_inner())
297 .clear();
298 }
299 }
300
301 pub fn new() -> Self {
303 Self {
304 registry: Default::default(),
305 active_custom_tool: None,
306 drawings: Vec::new(),
307 tool_defaults: Default::default(),
308 active_tool: None,
309 curr_drawing: None,
310 preview_point: false,
311 measurement: None,
312 next_id: 0,
313 pane_rect: Rect::NOTHING,
314 pane: None,
315 pane_members: vec![None],
316 pane_clips: std::collections::HashMap::new(),
317 text_editor: None,
318 finished_text_placement: None,
319 image_request: None,
320 selection: SelectionService::new(),
321 history: HistoryService::new(),
322 snap_service: SnapService::new(),
323 painted: Mutex::new(Vec::new()),
324 snap_targets: Vec::new(),
325 options: DrawingManagerOptions::default(),
326 dragging_handle: None,
327 drag_old_state: None,
328 drag_origin: None,
329 drag_group: Vec::new(),
330 drag_copy: false,
331 interaction_modifiers: egui::Modifiers::NONE,
332 place_at_pointer: false,
333 stay_in_drawing_mode: false,
334 curr_timeframe: String::from("1D"),
335 eraser_mode: false,
336 pending_sync: Arc::new(Mutex::new(Vec::new())),
337 sync_enabled: true,
338 }
339 }
340
341 pub fn sync_from_app_state(&mut self, drawing_state: &DrawingState) {
344 if self.active_tool != drawing_state.active_tool {
345 self.set_active_tool(drawing_state.active_tool);
346 }
347 self.options.magnet_mode = drawing_state.magnet_mode;
348 self.options.default_color = drawing_state.curr_color;
349 self.stay_in_drawing_mode = drawing_state.stay_in_drawing_mode;
350 if self.eraser_mode != drawing_state.eraser_mode {
351 self.set_eraser_mode(drawing_state.eraser_mode);
352 }
353
354 match (drawing_state.sel_drawing, self.selection.primary()) {
356 (Some(id), current) if current != Some(id) => {
357 self.select(id);
358 }
359 (None, Some(_)) => {
360 self.selection.deselect_all();
361 }
362 _ => {} }
364 }
365
366 pub fn sel_drawing(&self) -> Option<usize> {
370 self.selection
371 .primary()
372 .filter(|id| self.is_selected(*id))
373 .or_else(|| self.selected_ids().next())
374 }
375
376 pub fn select(&mut self, id: usize) {
378 self.select_drawings([id]);
379 }
380
381 pub fn select_drawings(&mut self, ids: impl IntoIterator<Item = usize>) {
383 self.selection.deselect_all();
384 self.selection.select_all(
385 ids.into_iter()
386 .filter(|id| self.drawings.iter().any(|d| d.id == *id && d.selectable)),
387 );
388 }
389
390 pub fn selected_ids(&self) -> impl Iterator<Item = usize> + '_ {
392 self.drawings
393 .iter()
394 .filter(|d| d.selectable && self.selection.is_selected(d.id))
395 .map(|d| d.id)
396 }
397
398 pub fn is_selected(&self, id: usize) -> bool {
400 self.drawings.iter().any(|d| d.id == id && d.selectable) && self.selection.is_selected(id)
401 }
402
403 pub fn toggle_selection(&mut self, id: usize) {
405 if self.drawings.iter().any(|d| d.id == id && d.selectable) {
406 self.selection.toggle(id);
407 }
408 }
409
410 pub fn edit_selected(&mut self, mut edit: impl FnMut(&mut Drawing)) {
412 let before: Vec<_> = self
413 .drawings
414 .iter()
415 .enumerate()
416 .filter(|(_, d)| d.selectable && self.selection.is_selected(d.id) && !d.locked)
417 .map(|(index, d)| (index, d.clone()))
418 .collect();
419 if before.is_empty() {
420 return;
421 }
422 for (index, old) in &before {
423 edit(&mut self.drawings[*index]);
424 self.queue_edit(old);
425 }
426 self.painted
427 .get_mut()
428 .unwrap_or_else(|e| e.into_inner())
429 .retain(|p| !before.iter().any(|(_, d)| d.id == p.id));
430 self.history.push_snapshot(before);
431 }
432
433 pub fn deselect(&mut self) {
435 self.selection.deselect_all();
436 }
437
438 pub fn select_at(&mut self, point: Pos2) -> bool {
443 if let Some(id) = self.hit_test(point) {
444 self.select(id);
445 true
446 } else {
447 self.selection.deselect_all();
448 false
449 }
450 }
451
452 pub(crate) fn swap_history(&mut self, history: &mut HistoryService) {
456 std::mem::swap(&mut self.history, history);
457 }
458
459 pub fn undo(&mut self) -> bool {
461 self.step_history(HistoryService::undo)
462 }
463
464 pub fn redo(&mut self) -> bool {
466 self.step_history(HistoryService::redo)
467 }
468
469 fn step_history(
471 &mut self,
472 step: fn(&mut HistoryService, &mut Vec<Drawing>) -> Option<usize>,
473 ) -> bool {
474 let before: Vec<_> = self
475 .drawings
476 .iter()
477 .filter(|d| d.saveable)
478 .map(|d| (d.id, sync_id(d)))
479 .collect();
480 let Some(_) = step(&mut self.history, &mut self.drawings) else {
481 return false;
482 };
483 self.painted
484 .get_mut()
485 .unwrap_or_else(|e| e.into_inner())
486 .retain(|p| !self.history.affected_ids().contains(&p.id));
487 for &id in self.history.affected_ids() {
488 let was = before.iter().find(|&&(i, _)| i == id);
489 match (was, self.drawings.iter().any(|d| d.id == id && d.saveable)) {
490 (Some(_), true) => self.queue(DrawingSyncAction::UpdateDrawing(id)),
491 (Some((_, stored_id)), false) => {
492 self.queue(DrawingSyncAction::Delete(stored_id.clone()))
493 }
494 (None, true) => self.queue(DrawingSyncAction::SaveDrawing(id)),
495 (None, false) => {}
496 }
497 }
498 self.selection
499 .prune(&self.drawings.iter().map(|d| d.id).collect());
500 true
501 }
502
503 fn queue(&self, action: DrawingSyncAction) {
505 if !self.sync_enabled {
506 return;
507 }
508 let drawing = match &action {
509 DrawingSyncAction::SaveDrawing(id) | DrawingSyncAction::UpdateDrawing(id) => {
510 self.drawings.iter().find(|drawing| drawing.id == *id)
511 }
512 _ => None,
513 };
514 if drawing.is_some_and(|drawing| !drawing.saveable) {
515 return;
516 }
517 if let Ok(mut sync) = self.pending_sync.lock() {
518 queue_sync(&mut sync, action, drawing.map(sync_id).as_deref());
519 }
520 }
521
522 pub fn can_undo(&self) -> bool {
524 self.history.can_undo()
525 }
526
527 pub fn can_redo(&self) -> bool {
529 self.history.can_redo()
530 }
531
532 pub fn update_snap_options(&mut self) {
537 self.snap_service = SnapService::with_options(SnapOptions {
538 snap_to_price: self.options.snap_to_price,
539 snap_to_time: self.options.snap_to_time,
540 snap_distance: self.options.snap_distance,
541 magnet_mode: self.options.magnet_mode,
542 magnet_distance: self.options.magnet_distance,
543 });
544 }
545
546 pub(crate) fn magnet_enabled(&self) -> bool {
547 self.options.magnet_mode
548 ^ (self.interaction_modifiers.ctrl || self.interaction_modifiers.command)
549 }
550
551 pub fn snap_point(&self, point: Pos2, drawings: &[Drawing]) -> Pos2 {
557 if self.magnet_enabled()
558 && self.options.strong_magnet
559 && let Some(target) = self
560 .snap_targets
561 .iter()
562 .filter(|p| p.is_finite())
563 .min_by(|a, b| (a.y - point.y).abs().total_cmp(&(b.y - point.y).abs()))
564 {
565 return *target;
566 }
567 let mut service = self.snap_service.clone();
568 service.set_magnet_mode(self.magnet_enabled());
569 service.snap_point_with_drawing_points(
570 point,
571 &[],
572 &[],
573 self.snap_targets.iter().copied().chain(
574 drawings
575 .iter()
576 .filter(|d| {
577 d.is_visible_on_timeframe(&self.curr_timeframe)
578 && self.pane_members.contains(&d.pane)
579 && self.dragging_handle.is_none_or(|(id, _)| id != d.id)
580 && self.drag_group.iter().all(|old| old.id != d.id)
581 })
582 .flat_map(|d| d.points.iter().copied()),
583 ),
584 )
585 }
586
587 fn queue_edit(&self, old: &Drawing) {
588 if let Some(drawing) = self.drawings.iter().find(|d| d.id == old.id) {
589 self.queue(match (old.saveable, drawing.saveable) {
590 (true, false) => DrawingSyncAction::Delete(sync_id(old)),
591 (false, true) => DrawingSyncAction::SaveDrawing(old.id),
592 _ => DrawingSyncAction::UpdateDrawing(old.id),
593 });
594 }
595 }
596
597 fn queue_update(&self, id: usize) {
599 if self.drawings.iter().any(|d| d.id == id) {
600 self.queue(DrawingSyncAction::UpdateDrawing(id));
601 }
602 }
603
604 fn take_id(&mut self) -> usize {
608 let floor = self
609 .drawings
610 .iter()
611 .flat_map(|d| {
612 [
613 Some(d.id),
614 d.stored_id
615 .as_deref()
616 .and_then(|s| s.parse().ok())
617 .filter(|&id: &usize| id < usize::MAX / 2),
618 ]
619 })
620 .flatten()
621 .max()
622 .map_or(0, |id: usize| id.saturating_add(1));
623 let id = self.next_id.max(floor);
624 self.next_id = id.saturating_add(1);
625 id
626 }
627
628 pub(crate) fn before_read(&self) -> BeforeRead {
630 BeforeRead {
631 ids: self
633 .drawings
634 .iter()
635 .filter(|d| d.saveable)
636 .map(|d| (sync_id(d), d.id))
637 .collect(),
638 next_id: self.next_id,
639 unsaved: self
640 .drawings
641 .iter()
642 .filter(|d| !d.saveable)
643 .cloned()
644 .collect(),
645 }
646 }
647
648 pub(crate) fn keep_across_read(&mut self, previous: BeforeRead) {
654 let BeforeRead {
655 ids,
656 next_id,
657 unsaved,
658 } = previous;
659 self.next_id = self.next_id.max(next_id);
660 let read = self.drawings.len();
662 self.drawings.extend(unsaved);
663 let mut fresh = Vec::new();
664 for (at, drawing) in self.drawings[..read].iter_mut().enumerate() {
665 match drawing.stored_id.as_ref().and_then(|id| ids.get(id)) {
666 Some(id) => drawing.id = *id,
667 None => fresh.push(at),
668 }
669 }
670 for at in fresh {
671 self.drawings[at].id = self.take_id();
672 }
673 }
674
675 pub fn new_drawing(&self, id: usize, tool: DrawingToolType) -> Drawing {
678 let mut drawing = match self.options.default_color {
679 Some(color) => Drawing::with_color(id, tool, color),
680 None => Drawing::new(id, tool),
681 };
682 if let Some(defaults) = self.tool_defaults.get(&tool) {
683 let _ = defaults.apply_to(&mut drawing);
684 }
685 drawing
686 }
687
688 pub(crate) fn top_z_order(&self) -> i32 {
690 self.drawings.iter().map(|d| d.z_order).max().unwrap_or(0)
691 }
692
693 pub(crate) fn visit_chart_points(&mut self, visit: &mut impl FnMut(&mut ChartPoint)) {
694 for drawing in self
695 .drawings
696 .iter_mut()
697 .chain(&mut self.curr_drawing)
698 .chain(&mut self.measurement)
699 .chain(&mut self.drag_old_state)
700 .chain(&mut self.drag_group)
701 {
702 drawing.chart_points.iter_mut().for_each(&mut *visit);
703 }
704 self.history.visit_chart_points(visit);
705 }
706}
707
708pub(crate) struct BeforeRead {
710 ids: std::collections::HashMap<String, usize>,
712 next_id: usize,
713 unsaved: Vec<Drawing>,
715}
716
717fn queue_sync(
719 sync: &mut Vec<DrawingSyncAction>,
720 action: DrawingSyncAction,
721 stored_id: Option<&str>,
722) {
723 if let DrawingSyncAction::SaveDrawing(id) | DrawingSyncAction::UpdateDrawing(id) = &action {
724 for pending in sync.iter_mut().rev() {
725 match pending {
726 DrawingSyncAction::Delete(deleted) if Some(deleted.as_str()) == stored_id => break,
727 DrawingSyncAction::SaveDrawing(other) | DrawingSyncAction::UpdateDrawing(other)
728 if other == id =>
729 {
730 if matches!(action, DrawingSyncAction::SaveDrawing(_)) {
731 *pending = action;
732 }
733 return;
734 }
735 _ => {}
736 }
737 }
738 }
739 sync.push(action);
740}
741
742pub(crate) fn sync_id(drawing: &Drawing) -> String {
744 drawing
745 .stored_id
746 .clone()
747 .unwrap_or_else(|| drawing.id.to_string())
748}
749
750#[cfg(test)]
751mod tests {
752 use super::*;
753
754 #[test]
758 fn a_drawing_finished_early_can_be_saved() {
759 use crate::drawings::persistence::{DrawingStorage, drawing_to_stored};
760 for (tool, finishes) in [
761 (DrawingToolType::Triangle, true),
762 (DrawingToolType::Pitchfork, false),
763 (DrawingToolType::SchiffPitchfork, false),
764 (DrawingToolType::ModifiedSchiffPitchfork, false),
765 (DrawingToolType::InsidePitchfork, false),
766 (DrawingToolType::FlatTopBottom, true),
767 (DrawingToolType::ParallelChannel, false),
768 ] {
769 let mut manager = DrawingManager::new();
770 let (x_to_bar, y_to_price) = (|x: f32| x, |y: f32| f64::from(y));
771 manager.start_drawing_with_coords(tool, Pos2::new(10.0, 10.0), x_to_bar, y_to_price);
772 manager.add_point_with_coords(Pos2::new(50.0, 40.0), x_to_bar, y_to_price);
773 manager.complete_curr_drawing();
774 assert_eq!(manager.drawings.len(), usize::from(finishes), "{tool:?}");
775 let mut storage = DrawingStorage::new("AAPL");
776 for drawing in &manager.drawings {
777 storage.add_drawing(drawing_to_stored(drawing, |bar| 60_000 * bar as i64));
778 }
779 assert!(
780 storage.to_json().is_ok(),
781 "{tool:?}: {:?}",
782 storage.to_json()
783 );
784 }
785 }
786
787 #[test]
791 fn hovering_between_clicks_adds_no_anchor() {
792 for (tool, clicks) in [
793 (DrawingToolType::TrendLine, 2),
794 (DrawingToolType::ParallelChannel, 3),
795 (DrawingToolType::XABCDPattern, 5),
796 (DrawingToolType::Polyline, 3),
797 ] {
798 let mut manager = DrawingManager::new();
799 let (x_to_bar, y_to_price) = (|x: f32| x, |y: f32| f64::from(y));
800 let click = |i: usize| Pos2::new(10.0 * (i + 1) as f32, 5.0 * (i % 2) as f32);
801 manager.start_drawing_with_coords(tool, click(0), x_to_bar, y_to_price);
802 for i in 1..clicks {
803 manager.update_last_point_with_coords(Pos2::new(99.0, 99.0), x_to_bar, y_to_price);
804 assert!(
805 manager.drawings.is_empty(),
806 "{tool:?} finished after {i} clicks"
807 );
808 manager.add_point_with_coords(click(i), x_to_bar, y_to_price);
809 }
810 manager.update_last_point_with_coords(Pos2::new(99.0, 99.0), x_to_bar, y_to_price);
811 manager.complete_curr_drawing();
812 let want: Vec<_> = (0..clicks)
813 .map(|i| ChartPoint::new(click(i).x, f64::from(click(i).y)))
814 .collect();
815 assert_eq!(manager.drawings.len(), 1, "{tool:?}");
816 assert_eq!(manager.drawings[0].chart_points, want, "{tool:?}");
817 }
818 }
819
820 #[test]
823 fn a_locked_drawing_is_neither_moved_nor_deleted() {
824 for bulk in [false, true] {
825 let edit = |manager: &mut DrawingManager, change: fn(&mut Drawing)| {
826 if bulk {
827 manager.edit_drawings(&[3], change);
828 } else {
829 manager.edit_drawing(3, change);
830 }
831 };
832 let mut manager = DrawingManager::new();
833 let mut line = Drawing::new(3, DrawingToolType::TrendLine);
834 line.chart_points = vec![ChartPoint::new(10.0, 10.0), ChartPoint::new(20.0, 20.0)];
835 line.update_screen_coords(|bar| bar, |price| price as f32);
836 line.locked = true;
837 manager.drawings.push(line);
838
839 manager.start_drag_handle(3, HandlePos::Start);
840 manager.update_drag_handle(
841 Pos2::new(50.0, 50.0),
842 |x| x,
843 f64::from,
844 |price, from, to| price + f64::from(to - from),
845 );
846 manager.end_drag_handle();
847 manager.delete_drawing(3);
848 edit(&mut manager, |d| d.font_size = 30.0);
849 manager.drag_edit_drawing(3, |d| d.font_size = 40.0);
850 manager.end_drag_handle();
851 assert_eq!(manager.drawings[0].font_size, 14.0);
852 assert!(!manager.can_undo());
853 assert!(manager.pending_sync.lock().unwrap().is_empty());
854 edit(&mut manager, |d| {
856 d.visible = false;
857 d.label = Some("Support".into());
858 d.folder = vec!["Analysis".into()];
859 d.font_size = 30.0;
860 });
861 assert!(!manager.drawings[0].visible);
862 assert_eq!(manager.drawings[0].label.as_deref(), Some("Support"));
863 assert_eq!(manager.drawings[0].folder, ["Analysis"]);
864 assert_eq!(manager.drawings[0].font_size, 14.0);
865 assert!(manager.drawings[0].locked);
866 assert!(manager.undo());
867 assert!(manager.drawings[0].visible);
868 assert_eq!(manager.drawings[0].label, None);
869 assert!(manager.drawings[0].folder.is_empty());
870 assert!(manager.drawings[0].locked);
871 edit(&mut manager, |d| d.locked = false);
872 edit(&mut manager, |d| d.font_size = 30.0);
873 assert_eq!(manager.drawings[0].font_size, 30.0);
874
875 assert_eq!(manager.drawings.len(), 1);
876 assert_eq!(
877 manager.drawings[0].chart_points[0],
878 ChartPoint::new(10.0, 10.0)
879 );
880 }
881 }
882
883 #[test]
886 fn bulk_edits_preserve_unrelated_host_changes_and_drawing_order() {
887 for edit in [
888 (|m: &mut DrawingManager| m.edit_drawings(&[2], |d| d.folder = vec!["Analysis".into()]))
889 as fn(&mut DrawingManager),
890 |m| m.edit_drawings(&[2], |d| d.visible = false),
891 |m| m.edit_drawings(&[2], |d| d.locked = true),
892 |m| {
893 m.select(2);
894 m.edit_selected(|d| d.color = [255, 0, 0, 255]);
895 },
896 |m| m.set_group(&[2], Some("Group".into())),
897 ] {
898 let mut manager = DrawingManager::new();
899 manager.drawings = (0..3)
900 .map(|id| Drawing::new(id, DrawingToolType::HorizontalLine))
901 .collect();
902 edit(&mut manager);
903 manager.pending_sync.lock().unwrap().clear();
904 manager.drawings[0].label = Some("Host update".into());
906 manager.drawings.remove(1);
907 manager
908 .drawings
909 .push(Drawing::new(3, DrawingToolType::HorizontalLine));
910 for step in [DrawingManager::undo, DrawingManager::redo] {
911 assert!(step(&mut manager));
912 assert_eq!(
913 manager.drawings.iter().map(|d| d.id).collect::<Vec<_>>(),
914 [0, 2, 3]
915 );
916 assert_eq!(manager.drawings[0].label.as_deref(), Some("Host update"));
917 assert!(matches!(
918 manager.pending_sync.lock().unwrap().as_slice(),
919 [DrawingSyncAction::UpdateDrawing(2)]
920 ));
921 }
922 }
923 }
924
925 #[test]
929 fn z_order_decides_which_overlapping_drawing_is_hit() {
930 use crate::drawings::services::ZOrderService;
931 let mut manager = DrawingManager::new();
932 for id in [1, 2] {
933 let mut line = Drawing::new(id, DrawingToolType::HorizontalLine);
934 line.chart_points = vec![ChartPoint::new(10.0, 50.0)];
935 line.update_screen_coords(|bar| bar, |price| price as f32);
936 manager.drawings.push(line);
937 }
938 let on_both = Pos2::new(30.0, 50.0);
939 let z = |m: &DrawingManager, id| m.drawings.iter().find(|d| d.id == id).unwrap().z_order;
940 assert_eq!(manager.hit_test(on_both), Some(2));
941
942 ZOrderService::bring_to_front(&mut manager.drawings, 1);
943 assert_eq!(manager.hit_test(on_both), Some(1));
944
945 manager.send_to_back(1);
946 assert_eq!(manager.hit_test(on_both), Some(2));
947 assert!(z(&manager, 1) < z(&manager, 2));
948
949 manager.bring_to_front(1);
950 assert_eq!(manager.hit_test(on_both), Some(1));
951 assert!(z(&manager, 1) > z(&manager, 2));
952
953 manager
955 .drawings
956 .iter_mut()
957 .find(|d| d.id == 1)
958 .unwrap()
959 .z_order = 5;
960 let mut fresh = Drawing::new(0, DrawingToolType::HorizontalLine);
961 fresh.chart_points = vec![ChartPoint::new(10.0, 50.0)];
962 fresh.update_screen_coords(|bar| bar, |price| price as f32);
963 manager.add_synced_drawing(fresh).unwrap();
964 let fresh_id = manager.drawings.last().unwrap().id;
965 assert_eq!(manager.hit_test(on_both), Some(fresh_id));
966
967 manager.paste_drawings(&[manager.drawings[1].clone()]);
969 manager.update_all_screen_coords(|bar| bar, |price| price as f32);
970 let copy_id = manager.drawings.last().unwrap().id;
971 assert_eq!(manager.hit_test(on_both), Some(copy_id));
972 assert!(manager.undo());
973 assert_eq!(manager.hit_test(on_both), Some(fresh_id));
974 }
975
976 #[test]
980 fn edits_queue_coalesced_sync_actions() {
981 fn line(id: usize) -> Drawing {
982 let mut drawing = Drawing::new(id, DrawingToolType::HorizontalLine);
983 drawing.chart_points = vec![ChartPoint::new(1.0, 10.0)];
984 drawing
985 }
986 type Edit = fn(&mut DrawingManager);
987 let cases: [(Edit, &[&str]); 15] = [
988 (
989 |m| {
990 m.duplicate_drawing(0);
991 m.undo();
992 m.redo();
993 },
994 &["Save(1)", "Delete(1)", "Save(1)"],
995 ),
996 (
997 |m| {
998 m.add_synced_drawing(line(1)).unwrap();
999 m.reorder_drawings(0, &[(0, 1), (1, 2)]);
1000 m.undo();
1001 m.redo();
1002 },
1003 &["Save(1)", "Update(0)"],
1004 ),
1005 (|m| m.clear_all(), &["Delete(a1b2)"]),
1006 (|m| m.toggle_visibility_all(), &["Update(0)"]),
1007 (|m| m.toggle_lock_all(), &["Update(0)"]),
1008 (|m| m.bring_to_front(0), &["Update(0)"]),
1009 (|m| m.send_to_back(0), &["Update(0)"]),
1010 (
1011 |m| {
1012 m.delete_drawing(0);
1013 m.undo();
1014 m.redo();
1015 },
1016 &["Delete(a1b2)", "Save(0)", "Delete(a1b2)"],
1017 ),
1018 (
1019 |m| {
1020 let rect = DrawingToolType::Rect;
1021 m.start_drawing_with_coords(rect, Pos2::new(1.0, 1.0), |x| x, f64::from);
1022 m.update_last_point_with_coords(Pos2::new(9.0, 9.0), |x| x, f64::from);
1023 m.complete_drag_drawing();
1024 },
1025 &["Save(1)"],
1026 ),
1027 (
1028 |m| {
1029 let line = DrawingToolType::Polyline;
1030 m.start_drawing_with_coords(line, Pos2::new(1.0, 1.0), |x| x, f64::from);
1031 m.add_point_with_coords(Pos2::new(9.0, 9.0), |x| x, f64::from);
1032 m.complete_curr_drawing();
1033 },
1034 &["Save(1)"],
1035 ),
1036 (
1037 |m| {
1038 m.add_synced_drawing(line(7)).unwrap();
1039 },
1040 &["Save(1)"],
1041 ),
1042 (
1043 |m| m.edit_drawing(0, |d| d.stroke_width = 3.0),
1044 &["Update(0)"],
1045 ),
1046 (
1047 |m| {
1049 for red in 0..120 {
1050 m.drag_edit_drawing(0, |d| d.color = [red, 0, 0, 255]);
1051 }
1052 m.end_drag_handle();
1053 m.undo();
1054 m.undo();
1055 },
1056 &["Update(0)"],
1057 ),
1058 (
1059 |m| {
1060 for i in 0..10_000 {
1061 m.edit_drawing(0, |d| d.stroke_width = 2.0 + (i % 2) as f32);
1062 }
1063 m.toggle_visibility_all();
1064 m.toggle_lock_all();
1065 },
1066 &["Update(0)"],
1067 ),
1068 (
1069 |m| {
1070 m.edit_drawing(0, |d| d.stroke_width = 2.0);
1071 m.add_synced_drawing(line(1)).unwrap();
1072 m.delete_drawing(1);
1073 m.edit_drawing(0, |d| d.stroke_width = 3.0);
1074 },
1075 &["Update(0)", "Save(1)", "Delete(1)"],
1076 ),
1077 ];
1078 for (case, (edit, expected)) in cases.into_iter().enumerate() {
1079 let mut manager = DrawingManager::new();
1080 let mut restored = line(5);
1081 restored.stored_id = Some("a1b2".into());
1082 manager.add_synced_drawing(restored).unwrap();
1083 let before = manager
1084 .drawings
1085 .iter()
1086 .map(|drawing| (drawing.id, serde_json::to_value(drawing).unwrap()))
1087 .collect::<Vec<_>>();
1088 edit(&mut manager);
1089 let after = manager
1090 .drawings
1091 .iter()
1092 .map(|drawing| (drawing.id, serde_json::to_value(drawing).unwrap()))
1093 .collect::<Vec<_>>();
1094 let queued: Vec<String> = manager
1095 .pending_sync
1096 .lock()
1097 .unwrap()
1098 .iter()
1099 .map(|action| match action {
1100 DrawingSyncAction::SaveDrawing(id) => format!("Save({id})"),
1101 DrawingSyncAction::UpdateDrawing(id) => format!("Update({id})"),
1102 DrawingSyncAction::Delete(id) => format!("Delete({id})"),
1103 DrawingSyncAction::RestoreDrawings(_) => "Restore".into(),
1104 })
1105 .collect();
1106 assert_eq!(queued, expected);
1107 if matches!(case, 0 | 2..=6 | 10 | 11) {
1108 assert!(manager.undo(), "case {case} must be undoable");
1109 assert_eq!(
1110 manager
1111 .drawings
1112 .iter()
1113 .map(|drawing| (drawing.id, serde_json::to_value(drawing).unwrap()))
1114 .collect::<Vec<_>>(),
1115 before,
1116 "case {case}: undo"
1117 );
1118 assert!(!manager.can_undo(), "case {case}: one command per edit");
1119 assert!(manager.redo(), "case {case}");
1120 assert_eq!(
1121 manager
1122 .drawings
1123 .iter()
1124 .map(|drawing| (drawing.id, serde_json::to_value(drawing).unwrap()))
1125 .collect::<Vec<_>>(),
1126 after,
1127 "case {case}: redo"
1128 );
1129 }
1130 }
1131 }
1132
1133 #[test]
1136 fn a_picked_icon_is_placed_by_the_next_click() {
1137 for glyph in [
1138 "icon:check",
1139 "icon:star",
1140 "😀",
1141 "👍",
1142 "🔥",
1143 "🍎",
1144 "🚀",
1145 "📌",
1146 "⭐",
1147 "sticker:bull",
1148 ] {
1149 for (keep_drawing, clicks) in [(false, 1), (true, 3)] {
1150 let mut manager = DrawingManager::new();
1151 manager.stay_in_drawing_mode = keep_drawing;
1152 manager.start_icon_insertion(glyph.into());
1153 manager.curr_drawing.as_mut().unwrap().font_size = 48.0;
1154 for i in 0..clicks {
1155 let tool = manager.active_tool.unwrap();
1156 assert_eq!(
1157 tool,
1158 if glyph.starts_with("sticker:") {
1159 DrawingToolType::Sticker
1160 } else {
1161 DrawingToolType::FontIcon
1162 }
1163 );
1164 let at = Pos2::new(5.0 * (i + 1) as f32, 5.0);
1165 manager.start_drawing_with_coords(tool, at, |x| x, f64::from);
1166 }
1167 let placed: Vec<_> = manager
1168 .drawings
1169 .iter()
1170 .map(|d| (d.text.as_deref(), d.font_size))
1171 .collect();
1172 assert_eq!(
1173 placed,
1174 vec![(Some(glyph), 48.0); clicks],
1175 "{glyph}, keep drawing {keep_drawing}"
1176 );
1177 }
1178 }
1179 }
1180
1181 #[test]
1182 fn test_selection() {
1183 let mut manager = DrawingManager::new();
1184 manager.select(1);
1185 assert_eq!(manager.sel_drawing(), None);
1186 manager.deselect();
1187 assert_eq!(manager.sel_drawing(), None);
1188 manager.drawings = (1..=3)
1189 .map(|id| Drawing::new(id, DrawingToolType::TrendLine))
1190 .collect();
1191 manager.drawings[2].selectable = false;
1192 manager.drawings[2].chart_points =
1193 vec![ChartPoint::new(0.0, 10.0), ChartPoint::new(100.0, 10.0)];
1194 manager.update_all_screen_coords(|x| x, |y| y as f32);
1195 assert_eq!(manager.hit_test(Pos2::new(50.0, 10.0)), None);
1196 assert_eq!(
1197 manager.hit_test_handle(&manager.drawings[2], Pos2::new(0.0, 10.0)),
1198 None
1199 );
1200 manager.select_drawings([1, 2, 3, 99]);
1201 assert_eq!(manager.selection.selection_count(), 2);
1202 manager.set_group(&[1, 2], Some("levels".into()));
1203 manager.select_group("levels");
1204 assert_eq!(manager.selected_ids().collect::<Vec<_>>(), [1, 2]);
1205 assert!(manager.undo());
1206 assert!(manager.group_drawings("levels").next().is_none());
1207 assert!(manager.redo());
1208 assert_eq!(manager.group_drawings("levels").count(), 2);
1209 manager.edit_selected(|drawing| {
1210 if drawing.id == 1 {
1211 drawing.selectable = false;
1212 }
1213 });
1214 assert_eq!(manager.sel_drawing(), Some(2));
1215 assert_eq!(manager.selected_ids().collect::<Vec<_>>(), [2]);
1216 assert!(manager.undo());
1217 manager.select_group("levels");
1218 manager.delete_selected();
1219 assert_eq!(
1220 manager.drawings.iter().map(|d| d.id).collect::<Vec<_>>(),
1221 [3]
1222 );
1223 assert_eq!(manager.sel_drawing(), None);
1224 }
1225
1226 #[test]
1231 fn test_hit_test_fibonacci_level_off_control_point() {
1232 let mut manager = DrawingManager::new();
1233 let mut fib = Drawing::new(7, DrawingToolType::FibonacciRetracement);
1234 fib.chart_points = vec![ChartPoint::new(100.0, 100.0), ChartPoint::new(300.0, 200.0)];
1236 fib.update_screen_coords(|bar| bar, |price| price as f32);
1237 fib.completed = true;
1238 manager.drawings.push(fib);
1239
1240 let click = Pos2::new(180.0, 176.4);
1243 for handle in [
1244 Pos2::new(100.0, 100.0),
1245 Pos2::new(300.0, 200.0),
1246 Pos2::new(200.0, 150.0),
1247 ] {
1248 let d = ((click.x - handle.x).powi(2) + (click.y - handle.y).powi(2)).sqrt();
1249 assert!(d > 20.0, "click must be off control points, was {d}px away");
1250 }
1251
1252 assert_eq!(manager.hit_test(click), Some(7));
1253 assert!(manager.select_at(click));
1254 assert_eq!(manager.sel_drawing(), Some(7));
1255 }
1256
1257 #[test]
1260 fn test_hit_test_channel_segment_off_control_point() {
1261 let mut manager = DrawingManager::new();
1262 let mut channel = Drawing::new(11, DrawingToolType::ParallelChannel);
1263 channel.points = vec![
1265 Pos2::new(100.0, 100.0),
1266 Pos2::new(300.0, 100.0),
1267 Pos2::new(200.0, 200.0),
1268 ];
1269 channel.completed = true;
1270 manager.drawings.push(channel);
1271
1272 let click = Pos2::new(150.0, 100.0);
1274 for handle in [
1275 Pos2::new(100.0, 100.0),
1276 Pos2::new(300.0, 100.0),
1277 Pos2::new(200.0, 200.0),
1278 ] {
1279 let d = ((click.x - handle.x).powi(2) + (click.y - handle.y).powi(2)).sqrt();
1280 assert!(d > 20.0, "click must be off control points, was {d}px away");
1281 }
1282
1283 assert_eq!(manager.hit_test(click), Some(11));
1284 }
1285
1286 #[test]
1288 fn magnet_snaps_to_anchors_except_the_group_being_dragged() {
1289 let mut manager = DrawingManager::new();
1290 manager.options.magnet_mode = true;
1291 manager.update_snap_options();
1292
1293 let mut d = Drawing::new(1, DrawingToolType::TrendLine);
1294 d.points = vec![Pos2::new(100.0, 100.0), Pos2::new(200.0, 200.0)];
1295 let drawings = vec![d];
1296
1297 let snapped = manager.snap_point(Pos2::new(103.0, 102.0), &drawings);
1299 assert_eq!(snapped, Pos2::new(100.0, 100.0));
1300
1301 manager.drawings = drawings;
1303 let mut peer = Drawing::new(2, DrawingToolType::TrendLine);
1304 peer.points = vec![Pos2::new(300.0, 300.0), Pos2::new(400.0, 400.0)];
1305 manager.drawings.push(peer);
1306 manager.select_drawings([1, 2]);
1307 manager.start_drag_body(1, Pos2::new(150.0, 150.0));
1308 let pointer = Pos2::new(303.0, 302.0);
1309 assert_eq!(manager.snap_point(pointer, &manager.drawings), pointer);
1310 }
1311
1312 #[test]
1316 fn position_stop_moves_independently_of_target() {
1317 let mut manager = DrawingManager::new();
1318 let x_to_bar = |x: f32| x;
1319 let y_to_price = |y: f32| f64::from(200.0 - y);
1320 manager.start_drawing_with_coords(
1321 DrawingToolType::LongPos,
1322 Pos2::new(10.0, 100.0),
1323 x_to_bar,
1324 y_to_price,
1325 );
1326 assert!(manager.curr_drawing.is_none());
1327 let id = manager.drawings[0].id;
1328 assert_eq!(manager.drawings[0].chart_points[2].price, 50.0);
1329
1330 manager.start_drag_handle(id, HandlePos::Point(2));
1331 manager.update_drag_handle(
1332 Pos2::new(50.0, 105.0),
1333 x_to_bar,
1334 y_to_price,
1335 |price, from, to| price - f64::from(to - from),
1336 );
1337 manager.end_drag_handle();
1338 let points = &manager.drawings[0].chart_points;
1339 assert_eq!((points[1].price, points[2].price), (150.0, 95.0));
1340 assert_eq!(points[2].bar_idx, 110.0);
1341
1342 manager.start_drag_handle(id, HandlePos::Point(1));
1343 manager.update_drag_handle(
1344 Pos2::new(40.0, 85.0),
1345 x_to_bar,
1346 y_to_price,
1347 |price, from, to| price - f64::from(to - from),
1348 );
1349 manager.end_drag_handle();
1350 let points = &manager.drawings[0].chart_points;
1351 assert_eq!((points[1].bar_idx, points[1].price), (110.0, 115.0));
1352 assert_eq!((points[2].bar_idx, points[2].price), (110.0, 95.0));
1353
1354 manager.start_drag_handle(id, HandlePos::End);
1355 manager.update_drag_handle(
1356 Pos2::new(260.0, 30.0),
1357 x_to_bar,
1358 y_to_price,
1359 |price, from, to| price - f64::from(to - from),
1360 );
1361 manager.end_drag_handle();
1362 let points = &manager.drawings[0].chart_points;
1363 assert_eq!((points[1].bar_idx, points[1].price), (260.0, 115.0));
1364 assert_eq!((points[2].bar_idx, points[2].price), (260.0, 95.0));
1365 }
1366
1367 #[test]
1370 fn app_state_with_a_tool_and_the_eraser_leaves_only_the_eraser() {
1371 let mut manager = DrawingManager::new();
1372 manager.sync_from_app_state(&DrawingState {
1373 active_tool: Some(DrawingToolType::TrendLine),
1374 eraser_mode: true,
1375 ..DrawingState::default()
1376 });
1377 assert!(manager.is_eraser_mode());
1378 assert_eq!(manager.active_tool(), None);
1379 }
1380
1381 #[test]
1385 fn creation_paths_add_only_drawings_that_save() {
1386 use crate::drawings::ToolAvailability;
1387 fn line(tool: DrawingToolType, anchors: &[(f32, f64)]) -> Drawing {
1388 let mut drawing = Drawing::new(0, tool);
1389 drawing.chart_points = anchors
1390 .iter()
1391 .map(|&(bar, price)| ChartPoint::new(bar, price))
1392 .collect();
1393 drawing.completed = true;
1394 drawing
1395 }
1396 fn place(m: &mut DrawingManager, tool: DrawingToolType, x_to_bar: fn(f32) -> f32) {
1397 let y_to_price = |y: f32| 200.0 - f64::from(y);
1398 m.start_drawing_with_coords(tool, Pos2::new(10.0, 95.0), x_to_bar, y_to_price);
1399 m.add_point_with_coords(Pos2::new(20.0, 94.0), x_to_bar, y_to_price);
1400 m.complete_curr_drawing();
1401 }
1402 const TWO: &[(f32, f64)] = &[(1.0, 105.0), (2.0, 106.0)];
1403 const NAN: &[(f32, f64)] = &[(1.0, 105.0), (2.0, f64::NAN)];
1404 const ONE: &[(f32, f64)] = &[(1.0, 105.0)];
1405 type Create = fn(&mut DrawingManager);
1406 let cases: [(&str, bool, Create); 12] = [
1407 ("duplicate, disabled tool", true, |m| {
1408 m.duplicate_drawing(m.drawings[0].id)
1409 }),
1410 ("copy drag, disabled tool", true, |m| {
1411 let id = m.drawings[0].id;
1412 m.start_drag_body(id, Pos2::ZERO);
1413 m.copy_dragged_drawings();
1414 m.end_drag_handle();
1415 }),
1416 ("paste, disabled tool", true, |m| {
1417 m.paste_drawings(&m.drawings.clone())
1418 }),
1419 ("paste, cursor", false, |m| {
1420 m.paste_drawings(&[line(DrawingToolType::Eraser, TWO)])
1421 }),
1422 ("paste, non-finite anchor", false, |m| {
1423 m.paste_drawings(&[line(DrawingToolType::TrendLine, NAN)])
1424 }),
1425 ("paste, one anchor", false, |m| {
1426 m.paste_drawings(&[line(DrawingToolType::TrendLine, ONE)])
1427 }),
1428 ("sync, cursor", false, |m| {
1429 let _ = m.add_synced_drawing(line(DrawingToolType::Eraser, TWO));
1430 }),
1431 ("sync, non-finite anchor", false, |m| {
1432 let _ = m.add_synced_drawing(line(DrawingToolType::TrendLine, NAN));
1433 }),
1434 ("sync, one anchor", false, |m| {
1435 let _ = m.add_synced_drawing(line(DrawingToolType::TrendLine, ONE));
1436 }),
1437 ("pointer, cursor", false, |m| {
1438 place(m, DrawingToolType::Eraser, |x| x / 10.0)
1439 }),
1440 ("pointer, non-finite anchor", false, |m| {
1441 place(m, DrawingToolType::TrendLine, |_| f32::NAN)
1442 }),
1443 ("pointer, unmappable anchor", false, |m| {
1444 place(m, DrawingToolType::TrendLine, |x| x * 1e30)
1445 }),
1446 ];
1447 for (case, disable, create) in cases {
1448 let mut chart = crate::widget::test_frames::chart(&[100.0], &[110.0]);
1449 let id = chart
1450 .drawings_mut()
1451 .add_drawing(line(DrawingToolType::TrendLine, TWO))
1452 .unwrap();
1453 if disable {
1454 chart.drawings_mut().registry.set_availability(
1455 DrawingToolType::TrendLine,
1456 ToolAvailability {
1457 visible: true,
1458 enabled: false,
1459 },
1460 );
1461 }
1462 create(chart.drawings_mut());
1463 let ids: Vec<_> = chart.drawings().drawings.iter().map(|d| d.id).collect();
1464 assert_eq!(ids, [id], "{case}");
1465 assert!(chart.save_drawings().is_ok(), "{case}");
1466 }
1467 }
1468}