1use crate::drawings::{Drawing, DrawingOptions, DrawingToolType, HandlePos};
11use crate::tokens::DESIGN_TOKENS;
12use egui::{Color32, Pos2, Rect, Stroke, epaint::StrokeKind};
13
14pub struct DrawingInteraction {
20 pub sel_drawing: Option<usize>,
22 pub dragging_handle: Option<(usize, HandlePos)>,
24 drag_old_state: Option<Drawing>,
26}
27
28impl Default for DrawingInteraction {
29 fn default() -> Self {
30 Self::new()
31 }
32}
33
34impl DrawingInteraction {
35 pub fn new() -> Self {
37 Self {
38 sel_drawing: None,
39 dragging_handle: None,
40 drag_old_state: None,
41 }
42 }
43
44 pub fn deselect(&mut self) {
46 self.sel_drawing = None;
47 }
48
49 pub fn get_handles(&self, drawing: &Drawing) -> Vec<(HandlePos, Pos2)> {
53 super::HandleService::get_handles(drawing)
54 }
55
56 pub fn hit_test_handle(
61 &self,
62 point: Pos2,
63 drawing: &Drawing,
64 handle_size: f32,
65 ) -> Option<HandlePos> {
66 let handles = self.get_handles(drawing);
67 let hit_distance = handle_size * 1.5;
68
69 for (pos, handle_point) in handles {
70 let dist =
71 ((point.x - handle_point.x).powi(2) + (point.y - handle_point.y).powi(2)).sqrt();
72 if dist <= hit_distance {
73 return Some(pos);
74 }
75 }
76 None
77 }
78
79 pub fn start_drag_handle(&mut self, drawing: &Drawing, handle: HandlePos) {
81 self.drag_old_state = Some(drawing.clone());
82 self.dragging_handle = Some((drawing.id, handle));
83 }
84
85 pub fn take_drag_old_state(&mut self) -> Option<Drawing> {
88 self.drag_old_state.take()
89 }
90
91 pub fn end_drag_handle(&mut self) -> Option<Drawing> {
94 self.dragging_handle = None;
95 self.drag_old_state.take()
96 }
97
98 pub fn is_dragging(&self) -> bool {
100 self.dragging_handle.is_some()
101 }
102
103 pub fn get_drag_target(&self) -> Option<(usize, HandlePos)> {
106 self.dragging_handle
107 }
108
109 pub fn render_handles(
113 &self,
114 painter: &egui::Painter,
115 drawing: &Drawing,
116 options: &DrawingOptions,
117 ) {
118 if !options.show_handles {
119 return;
120 }
121
122 let handles = self.get_handles(drawing);
123 let size = options.handle_size;
124
125 for (pos, point) in handles {
126 let is_dragging = self
127 .dragging_handle
128 .map(|(id, h)| id == drawing.id && h == pos)
129 .unwrap_or(false);
130
131 let color = if is_dragging {
132 Color32::from_rgba_unmultiplied(
133 options.handle_sel_color[0],
134 options.handle_sel_color[1],
135 options.handle_sel_color[2],
136 options.handle_sel_color[3],
137 )
138 } else {
139 Color32::from_rgba_unmultiplied(
140 options.handle_color[0],
141 options.handle_color[1],
142 options.handle_color[2],
143 options.handle_color[3],
144 )
145 };
146
147 let rect = Rect::from_center_size(point, egui::vec2(size, size));
149 painter.rect_filled(rect, 1.0, color);
150 painter.rect_stroke(
151 rect,
152 1.0,
153 Stroke::new(
154 DESIGN_TOKENS.stroke.hairline,
155 DESIGN_TOKENS.semantic.extended.chart_bg,
156 ),
157 StrokeKind::Outside,
158 );
159 }
160 }
161
162 pub fn hit_test(&self, point: Pos2, drawings: &[Drawing], timeframe: &str) -> Option<usize> {
170 let hit_distance = 5.0;
171
172 for drawing in super::in_z_order(drawings).into_iter().rev() {
173 if !drawing.visible {
175 continue;
176 }
177
178 if !drawing.timeframe_visibility.is_visible_on(timeframe) {
180 continue;
181 }
182
183 match drawing.tool_type {
184 DrawingToolType::HorizontalLine => {
185 if !drawing.points.is_empty()
186 && (point.y - drawing.points[0].y).abs() <= hit_distance
187 {
188 return Some(drawing.id);
189 }
190 }
191 DrawingToolType::VerticalLine => {
192 if !drawing.points.is_empty()
193 && (point.x - drawing.points[0].x).abs() <= hit_distance
194 {
195 return Some(drawing.id);
196 }
197 }
198 DrawingToolType::Rect => {
199 if drawing
200 .rectangle_bounds(Rect::EVERYTHING)
201 .is_some_and(|rect| rect.contains(point))
202 {
203 return Some(drawing.id);
204 }
205 }
206 DrawingToolType::FibonacciRetracement | DrawingToolType::FibonacciExtension => {
209 if drawing.points.len() >= 2 {
210 let config = drawing.fibonacci_config();
211 let on_level = drawing
212 .fib_level_x_range(&config, Rect::EVERYTHING)
213 .contains(point.x)
214 && drawing.level_ys.iter().any(|y| {
215 (point.y - y).abs() <= hit_distance.max(config.level_width / 2.0)
216 });
217 let on_trend = drawing
218 .points
219 .windows(2)
220 .any(|p| point_to_line_distance(point, p[0], p[1]) <= hit_distance);
221 if on_level || on_trend {
222 return Some(drawing.id);
223 }
224 }
225 }
226 DrawingToolType::FibonacciChannel => {
227 let reach = Rect::from_points(&drawing.points)
229 .union(Rect::from_pos(point))
230 .expand(2.0 * hit_distance);
231 if drawing
232 .fib_channel_level_lines(reach)
233 .is_some_and(|(_, lines)| {
234 lines
235 .iter()
236 .any(|&[a, b]| point_to_line_distance(point, a, b) <= hit_distance)
237 })
238 {
239 return Some(drawing.id);
240 }
241 }
242 _ => {
243 let reach = Rect::from_points(&drawing.points)
246 .union(Rect::from_pos(point))
247 .expand(2.0 * hit_distance);
248 if let Some(outline) = drawing.outline(reach) {
249 let tolerance = hit_distance.max(outline.width / 2.0);
250 let on_line = outline
251 .lines
252 .iter()
253 .any(|&[a, b]| point_to_line_distance(point, a, b) <= tolerance);
254 if on_line
255 || outline
256 .areas
257 .iter()
258 .any(|area| convex_contains(area, point))
259 {
260 return Some(drawing.id);
261 }
262 continue;
263 }
264 for &p in drawing.points.iter().skip(drawing.first_moved_anchor()) {
266 let dist = ((point.x - p.x).powi(2) + (point.y - p.y).powi(2)).sqrt();
267 if dist <= hit_distance * 2.0 {
268 return Some(drawing.id);
269 }
270 }
271 }
272 }
273 }
274 None
275 }
276
277 pub fn hit_test_painted(point: Pos2, shapes: &[egui::epaint::ClippedShape]) -> bool {
283 shapes
284 .iter()
285 .any(|s| s.clip_rect.contains(point) && hit_shape(point, &s.shape))
286 }
287
288 pub fn select_at(&mut self, point: Pos2, drawings: &[Drawing], timeframe: &str) -> bool {
293 if let Some(id) = self.hit_test(point, drawings, timeframe) {
294 self.sel_drawing = Some(id);
295 true
296 } else {
297 self.sel_drawing = None;
298 false
299 }
300 }
301}
302
303fn hit_shape(point: Pos2, shape: &egui::Shape) -> bool {
306 use egui::Shape;
307 if !shape.visual_bounding_rect().expand(5.0).contains(point) {
308 return false;
309 }
310 let line = |a, b, width: f32| point_to_line_distance(point, a, b) <= 5.0_f32.max(width / 2.0);
311 let path = |points: &[Pos2], closed: bool, fill: bool, width: f32| {
312 (fill && polygon_contains(points, point))
313 || (width > 0.0
314 && (points.windows(2).any(|p| line(p[0], p[1], width))
315 || (closed
316 && points.len() > 1
317 && line(points[points.len() - 1], points[0], width))))
318 };
319 match shape {
320 Shape::Noop | Shape::Callback(_) | Shape::Band(_) => false,
321 Shape::Vec(shapes) => shapes.iter().any(|s| hit_shape(point, s)),
322 Shape::LineSegment { points, stroke } => {
323 stroke.color.a() > 0 && stroke.width > 0.0 && line(points[0], points[1], stroke.width)
324 }
325 Shape::Path(p) => path(
326 &p.points,
327 p.closed,
328 p.fill.a() > 0,
329 if p.stroke.is_empty() {
330 0.0
331 } else {
332 p.stroke.width
333 },
334 ),
335 Shape::Circle(c) => {
336 let distance = point.distance(c.center);
337 (c.fill.a() > 0 && distance <= c.radius)
338 || (!c.stroke.is_empty()
339 && (distance - c.radius).abs() <= 5.0_f32.max(c.stroke.width / 2.0))
340 }
341 Shape::Ellipse(e) => {
342 let ring: Vec<_> = (0..64)
343 .map(|i| {
344 let angle = i as f32 * std::f32::consts::TAU / 64.0;
345 e.center + e.radius * egui::vec2(angle.cos(), angle.sin())
346 })
347 .collect();
348 path(
349 &ring,
350 true,
351 e.fill.a() > 0,
352 if e.stroke.is_empty() {
353 0.0
354 } else {
355 e.stroke.width
356 },
357 )
358 }
359 Shape::Rect(r) => {
360 let corners = [
361 r.rect.left_top(),
362 r.rect.right_top(),
363 r.rect.right_bottom(),
364 r.rect.left_bottom(),
365 ];
366 path(
367 &corners,
368 true,
369 r.fill.a() > 0,
370 if r.stroke.is_empty() {
371 0.0
372 } else {
373 r.stroke.width
374 },
375 )
376 }
377 Shape::Text(t) => t
379 .galley
380 .mesh_bounds
381 .contains((egui::emath::Rot2::from_angle(-t.angle) * (point - t.pos)).to_pos2()),
382 Shape::Mesh(m) => m.indices.as_chunks::<3>().0.iter().any(|tri| {
383 let points = [
384 m.vertices[tri[0] as usize].pos,
385 m.vertices[tri[1] as usize].pos,
386 m.vertices[tri[2] as usize].pos,
387 ];
388 polygon_contains(&points, point)
389 }),
390 Shape::QuadraticBezier(b) => hit_shape(point, &Shape::Path(b.to_path_shape(Some(0.5)))),
391 Shape::CubicBezier(b) => b
392 .to_path_shapes(Some(0.5), None)
393 .iter()
394 .any(|p| hit_shape(point, &Shape::Path(p.clone()))),
395 }
396}
397
398fn polygon_contains(points: &[Pos2], point: Pos2) -> bool {
400 let mut inside = false;
401 for i in 0..points.len() {
402 let (a, b) = (points[i], points[(i + 1) % points.len()]);
403 if (a.y > point.y) != (b.y > point.y)
404 && point.x < a.x + (point.y - a.y) * (b.x - a.x) / (b.y - a.y)
405 {
406 inside = !inside;
407 }
408 }
409 inside
410}
411
412fn convex_contains(area: &[Pos2], point: Pos2) -> bool {
415 let sides = area.len();
416 let crosses = (0..sides).map(|i| {
417 let (edge, to_point) = (area[(i + 1) % sides] - area[i], point - area[i]);
418 edge.x * to_point.y - edge.y * to_point.x
419 });
420 let (mut left, mut right) = (false, false);
421 for cross in crosses {
422 left |= cross > 0.0;
423 right |= cross < 0.0;
424 }
425 sides >= 3 && (left || right) && !(left && right)
426}
427
428pub fn point_to_line_distance(point: Pos2, line_start: Pos2, line_end: Pos2) -> f32 {
434 let dx = line_end.x - line_start.x;
435 let dy = line_end.y - line_start.y;
436 let len_sq = dx * dx + dy * dy;
437
438 if len_sq < 1e-6 {
439 return ((point.x - line_start.x).powi(2) + (point.y - line_start.y).powi(2)).sqrt();
441 }
442
443 let t = ((point.x - line_start.x) * dx + (point.y - line_start.y) * dy) / len_sq;
445 let t = t.clamp(0.0, 1.0);
446
447 let proj_x = line_start.x + t * dx;
448 let proj_y = line_start.y + t * dy;
449
450 ((point.x - proj_x).powi(2) + (point.y - proj_y).powi(2)).sqrt()
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456 use crate::drawings::ChartPoint;
457
458 #[test]
462 fn turned_text_is_hit_over_its_own_box() {
463 use egui::epaint::{ClippedShape, TextShape};
464 let pos = Pos2::new(100., 100.);
465 let hits = |shape: &TextShape, point: Pos2| {
466 DrawingInteraction::hit_test_painted(
467 point,
468 &[ClippedShape::new(
469 Rect::EVERYTHING,
470 egui::Shape::Text(shape.clone()),
471 )],
472 )
473 };
474 egui::Context::default()
475 .run_ui(Default::default(), |ui| {
476 let galley = ui.painter().layout_no_wrap(
477 "WWWWWWWW".into(),
478 egui::FontId::proportional(20.0),
479 Color32::WHITE,
480 );
481 let bounds = galley.mesh_bounds;
482 let upright = TextShape::new(pos, galley, Color32::WHITE);
483 let turned = upright.clone().with_angle(std::f32::consts::FRAC_PI_4);
484 let center =
485 egui::emath::Rot2::from_angle(turned.angle) * bounds.center().to_vec2();
486 assert!(hits(&turned, pos + center));
487 let corner = turned.visual_bounding_rect().right_top() + egui::vec2(-1., 1.);
488 assert!(!hits(&turned, corner));
489 assert!(hits(&upright, pos + bounds.center().to_vec2()));
490 let past = Pos2::new(bounds.max.x + 1., bounds.center().y);
491 assert!(!hits(&upright, past + pos.to_vec2()));
492 })
493 .drop_without_applying_deltas();
494 }
495
496 #[test]
497 fn test_point_to_line_distance() {
498 let dist = point_to_line_distance(
500 Pos2::new(50.0, 50.0),
501 Pos2::new(0.0, 0.0),
502 Pos2::new(100.0, 100.0),
503 );
504 assert!(dist < 0.01);
505
506 let dist = point_to_line_distance(
508 Pos2::new(50.0, 60.0),
509 Pos2::new(0.0, 50.0),
510 Pos2::new(100.0, 50.0),
511 );
512 assert!((dist - 10.0).abs() < 0.01);
513 }
514
515 #[test]
518 fn fib_retracement_hit_follows_level_orientation() {
519 let interaction = DrawingInteraction::new();
520 let mut fib = Drawing::new(7, DrawingToolType::FibonacciRetracement);
521 fib.chart_points = vec![ChartPoint::new(0.0, 100.0), ChartPoint::new(10.0, 400.0)];
522 let hit = |fib: &mut Drawing, y| {
523 fib.update_screen_coords(|bar| bar * 30.0, |price| 500.0 - price as f32);
524 interaction.hit_test(Pos2::new(150.0, y), std::slice::from_ref(fib), "1D")
525 };
526
527 assert_eq!(hit(&mut fib, 170.8), Some(7));
528 fib.fib_config = Some(crate::drawings::FibonacciConfig {
529 reverse: true,
530 ..Default::default()
531 });
532 assert_eq!(hit(&mut fib, 170.8), None);
533 assert_eq!(hit(&mut fib, 329.2), Some(7));
534 }
535
536 #[test]
538 fn fib_level_hits_follow_the_price_scale() {
539 use crate::drawings::{FibonacciConfig, FibonacciLevel};
540 let interaction = DrawingInteraction::new();
541 for (tool, logarithmic, ratio, want) in [
542 (DrawingToolType::FibonacciRetracement, false, 0.5, 505.0),
543 (DrawingToolType::FibonacciRetracement, false, 0.99, 990.1),
544 (DrawingToolType::FibonacciExtension, false, 0.618, 621.82),
545 (
546 DrawingToolType::FibonacciRetracement,
547 true,
548 0.236,
549 29.64831389524342,
550 ),
551 (DrawingToolType::FibonacciRetracement, true, 0.5, 100.0),
552 (
553 DrawingToolType::FibonacciRetracement,
554 true,
555 0.618,
556 172.1868574986006,
557 ),
558 (DrawingToolType::FibonacciExtension, true, 0.5, 100.0),
559 ] {
560 for log_scale in [false, true] {
561 let price_to_y = |price: f64| {
562 if log_scale {
563 (500.0 * (2000f64 / price).ln() / (2000f64 / 5.0).ln()) as f32
564 } else {
565 (500.0 - price * 0.2) as f32
566 }
567 };
568 let mut fib = Drawing::new(7, tool);
569 let (start, end) = if tool == DrawingToolType::FibonacciRetracement {
570 (1000.0, 10.0)
571 } else {
572 (10.0, 1000.0)
573 };
574 fib.chart_points = vec![ChartPoint::new(0.0, start), ChartPoint::new(10.0, end)];
575 let mut unlabelled = FibonacciLevel::new(ratio - 0.001, "", [33, 150, 243, 255]);
577 unlabelled.show_price = false;
578 fib.fib_config = Some(FibonacciConfig {
579 logarithmic,
580 levels_as_percent: true,
581 show_background: true,
582 levels: vec![
583 unlabelled,
584 FibonacciLevel::new(ratio, "level", [33, 150, 243, 255]),
585 FibonacciLevel::new(1.0, "end", [33, 150, 243, 255]),
586 ],
587 ..Default::default()
588 });
589 fib.update_screen_coords(|bar| bar * 30.0, price_to_y);
590 let y = price_to_y(want);
591 let hit =
592 interaction.hit_test(Pos2::new(150.0, y), std::slice::from_ref(&fib), "1D");
593 assert_eq!(
594 hit,
595 Some(7),
596 "{tool:?}, log levels {logarithmic}, log scale {log_scale}"
597 );
598 let rect = Rect::from_min_max(Pos2::ZERO, Pos2::new(400.0, 500.0));
599 let mut output = egui::Context::default().run_ui(egui::RawInput::default(), |ui| {
600 let view = crate::drawings::rendering::ChartView {
601 price_formatter: None,
602 time_formatter: None,
603 bar_seconds: 0,
604 instrument: None,
605 bars: &[],
606 revision: 0,
607 bar_to_x: &|b| b * 30.0,
608 price_to_y: &price_to_y,
609 y_to_price: &f64::from,
610 time_axis: true,
611 executions: None,
612 timeframe: None,
613 finer: &[],
614 };
615 fib.render_fibonacci(ui.painter(), rect, &view);
616 });
617 output.textures_delta.clear();
618 let line = output.shapes.iter().any(|s| matches!(&s.shape,
619 egui::Shape::LineSegment { points, .. } if (points[0].y-y).abs() < 1e-3 && (points[1].y-y).abs() < 1e-3));
620 let label = output.shapes.iter().any(|s| {
621 matches!(&s.shape,
622 egui::Shape::Text(t) if t.galley.text() == format!("level ({want:.2})")
623 && t.pos.y + t.galley.size().y < y)
624 });
625 let band = output.shapes.iter().any(|s| matches!(&s.shape,
626 egui::Shape::Rect(r) if (r.rect.top()-y).abs() < 1e-3 || (r.rect.bottom()-y).abs() < 1e-3));
627 let labels: Vec<_> = output
628 .shapes
629 .iter()
630 .filter_map(|s| match &s.shape {
631 egui::Shape::Text(t) => Some(Rect::from_min_size(t.pos, t.galley.size())),
632 _ => None,
633 })
634 .collect();
635 assert_eq!(labels.len(), 2, "nearby levels retain both labels");
636 output.drop_without_applying_deltas();
637 assert!(
638 line && label && band,
639 "{tool:?}, log levels {logarithmic}, log scale {log_scale}"
640 );
641 }
642 }
643 }
644
645 #[test]
655 fn fib_drawings_are_hit_along_the_levels_they_draw() {
656 use crate::drawings::FibonacciConfig;
657 let interaction = DrawingInteraction::new();
658 let extended = || {
659 Some(FibonacciConfig {
660 extend_right: true,
661 ..Default::default()
662 })
663 };
664 let cases = [
665 (
666 DrawingToolType::FibonacciRetracement,
667 None,
668 (450.0, 170.8),
669 None,
670 ),
671 (
672 DrawingToolType::FibonacciRetracement,
673 extended(),
674 (450.0, 170.8),
675 Some(7),
676 ),
677 (
678 DrawingToolType::FibonacciRetracement,
679 None,
680 (90.0, 310.0),
681 Some(7),
682 ),
683 (
684 DrawingToolType::FibonacciExtension,
685 None,
686 (90.0, 310.0),
687 Some(7),
688 ),
689 (
690 DrawingToolType::FibonacciChannel,
691 None,
692 (150.0, 300.0),
693 Some(7),
694 ),
695 (
696 DrawingToolType::FibonacciChannel,
697 None,
698 (150.0, 214.6),
699 None,
700 ),
701 (
702 DrawingToolType::FibonacciChannel,
703 extended(),
704 (400.0, 50.0),
705 Some(7),
706 ),
707 ];
708 let mut wrong = Vec::new();
709 for (tool, config, (x, y), want) in cases {
710 let mut fib = Drawing::new(7, tool);
711 fib.fib_config = config;
712 fib.chart_points = vec![ChartPoint::new(0.0, 100.0), ChartPoint::new(10.0, 400.0)];
713 if tool == DrawingToolType::FibonacciChannel {
714 fib.chart_points.push(ChartPoint::new(5.0, 150.0));
715 }
716 fib.update_screen_coords(|bar| bar * 30.0, |price| 500.0 - price as f32);
717 let extend = fib.fib_config.as_ref().is_some_and(|c| c.extend_right);
718 let got = interaction.hit_test(Pos2::new(x, y), std::slice::from_ref(&fib), "1D");
719 if got != want {
720 wrong.push(format!(
721 "{tool:?} extend_right {extend} at ({x}, {y}): got {got:?}, want {want:?}"
722 ));
723 }
724 }
725 assert!(wrong.is_empty(), "{}", wrong.join("\n"));
726 }
727
728 #[test]
731 fn drawings_without_screen_points_are_not_hit() {
732 let interaction = DrawingInteraction::new();
733 let hit: Vec<_> = DrawingToolType::all()
734 .iter()
735 .filter(|&&tool| {
736 let mut drawing = Drawing::new(7, tool);
737 drawing.chart_points = (0..5)
738 .map(|i| ChartPoint::new(i as f32, 100.0 + f64::from(i)))
739 .collect();
740 interaction
741 .hit_test(Pos2::new(1.0, 1.0), std::slice::from_ref(&drawing), "1D")
742 .is_some()
743 })
744 .collect();
745 assert!(hit.is_empty(), "hit without screen points: {hit:?}");
746 }
747
748 #[test]
772 fn drawings_are_hit_along_what_they_draw() {
773 use DrawingToolType as T;
774 let interaction = DrawingInteraction::new();
775 let cases = [
777 (
778 T::Ray,
779 (300.0, 150.0),
780 Some(7),
781 vec![(0.0, 0.0), (100.0, 50.0)],
782 ),
783 (
784 T::Ray,
785 (-100.0, -50.0),
786 Some(7),
787 vec![(0.0, 0.0), (100.0, 50.0)],
788 ),
789 (
790 T::TrendLine,
791 (500.0, 300.0),
792 Some(7),
793 vec![(100.0, 100.0), (200.0, 150.0)],
794 ),
795 (
796 T::TrendLine,
797 (0.0, 50.0),
798 Some(7),
799 vec![(100.0, 100.0), (200.0, 150.0)],
800 ),
801 (
802 T::Ray,
803 (200.0, 250.0),
804 Some(7),
805 vec![(500.0, 100.0), (400.0, 150.0)],
806 ),
807 (T::CrossLine, (100.0, 300.0), Some(7), vec![(100.0, 100.0)]),
808 (
809 T::XABCDPattern,
810 (150.0, 50.0),
811 Some(7),
812 vec![
813 (0.0, 0.0),
814 (100.0, 100.0),
815 (200.0, 0.0),
816 (300.0, 100.0),
817 (400.0, 0.0),
818 ],
819 ),
820 (
821 T::HeadAndShoulders,
822 (300.0, 200.0),
823 Some(7),
824 vec![
825 (0.0, 450.0),
826 (100.0, 150.0),
827 (200.0, 300.0),
828 (300.0, 0.0),
829 (400.0, 300.0),
830 (500.0, 150.0),
831 (600.0, 450.0),
832 ],
833 ),
834 (
835 T::HeadAndShoulders,
836 (200.0, 200.0),
837 None,
838 vec![
839 (0.0, 450.0),
840 (100.0, 150.0),
841 (200.0, 300.0),
842 (300.0, 0.0),
843 (400.0, 300.0),
844 (500.0, 150.0),
845 (600.0, 450.0),
846 ],
847 ),
848 (
849 T::TrianglePattern,
850 (300.0, 100.0),
851 Some(7),
852 vec![(0.0, 0.0), (0.0, 200.0), (200.0, 50.0), (200.0, 150.0)],
853 ),
854 (
855 T::Triangle,
856 (150.0, 100.0),
857 Some(7),
858 vec![(0.0, 0.0), (200.0, 0.0), (100.0, 200.0)],
859 ),
860 (
861 T::Triangle,
862 (100.0, 50.0),
863 None,
864 vec![(0.0, 0.0), (200.0, 0.0), (100.0, 200.0)],
865 ),
866 (
867 T::Circle,
868 (100.0, 150.0),
869 Some(7),
870 vec![(100.0, 100.0), (150.0, 100.0)],
871 ),
872 (
873 T::Circle,
874 (100.0, 100.0),
875 None,
876 vec![(100.0, 100.0), (150.0, 100.0)],
877 ),
878 (
879 T::Pitchfork,
880 (400.0, 100.0),
881 Some(7),
882 vec![(0.0, 100.0), (100.0, 50.0), (100.0, 150.0)],
883 ),
884 (
885 T::GannFan,
886 (300.0, 50.0),
887 Some(7),
888 vec![(0.0, 200.0), (100.0, 100.0)],
889 ),
890 (
891 T::Measure,
892 (20.0, 80.0),
893 Some(7),
894 vec![(0.0, 0.0), (100.0, 100.0)],
895 ),
896 (
897 T::Measure,
898 (50.0, 102.0),
899 Some(7),
900 vec![(0.0, 100.0), (100.0, 100.0)],
901 ),
902 (
903 T::Measure,
904 (500.0, 100.0),
905 None,
906 vec![(0.0, 100.0), (100.0, 100.0)],
907 ),
908 (
909 T::Highlighter,
910 (50.0, 7.0),
911 Some(7),
912 vec![(0.0, 0.0), (100.0, 0.0)],
913 ),
914 (
915 T::Highlighter,
916 (50.0, 9.0),
917 None,
918 vec![(0.0, 0.0), (100.0, 0.0)],
919 ),
920 (
921 T::ArrowMarker,
922 (240.0, 230.0),
923 Some(7),
924 vec![(100.0, 200.0), (300.0, 200.0)],
925 ),
926 (
927 T::ArrowMarker,
928 (200.0, 225.0),
929 None,
930 vec![(100.0, 200.0), (300.0, 200.0)],
931 ),
932 (
933 T::PriceRange,
934 (120.0, 65.0),
935 Some(7),
936 vec![(100.0, 50.0), (200.0, 80.0)],
937 ),
938 (
939 T::DateRange,
940 (150.0, 400.0),
941 None,
942 vec![(100.0, 50.0), (200.0, 80.0)],
943 ),
944 (
945 T::Ellipse,
946 (100.0, 100.0),
947 Some(7),
948 vec![(0.0, 0.0), (200.0, 100.0)],
949 ),
950 (
951 T::Ellipse,
952 (100.0, 50.0),
953 None,
954 vec![(0.0, 0.0), (200.0, 100.0)],
955 ),
956 ];
957 let mut wrong = Vec::new();
958 for (tool, (x, y), want, anchors) in cases {
959 let mut drawing = Drawing::new(7, tool);
960 if matches!(tool, T::Triangle | T::Circle | T::Ellipse) {
962 drawing.fill_color = None;
963 }
964 if tool == T::Highlighter {
965 drawing.stroke_width = 16.0;
967 }
968 drawing.points = anchors.iter().map(|&(x, y)| Pos2::new(x, y)).collect();
969 (drawing.extend_left, drawing.extend_right) = (true, true);
970 let got = interaction.hit_test(Pos2::new(x, y), std::slice::from_ref(&drawing), "1D");
971 if got != want {
972 wrong.push(format!(
973 "{tool:?} at ({x}, {y}): got {got:?}, want {want:?}"
974 ));
975 }
976 }
977 assert!(wrong.is_empty(), "{}", wrong.join("\n"));
978 }
979
980 #[test]
981 fn test_selection() {
982 let mut interaction = DrawingInteraction::new();
983 let mut drawing = Drawing::new(5, DrawingToolType::HorizontalLine);
984 drawing.points = vec![Pos2::new(0.0, 100.0)];
985 let drawings = [drawing];
986 let hit = Pos2::new(50.0, 102.0);
987 assert!(interaction.select_at(hit, &drawings, "1D"));
988 assert_eq!(interaction.sel_drawing, Some(5));
989 assert!(!interaction.select_at(Pos2::new(50.0, 150.0), &drawings, "1D"));
990 assert_eq!(interaction.sel_drawing, None);
991 assert!(interaction.select_at(hit, &drawings, "1D"));
992 assert_eq!(interaction.sel_drawing, Some(5));
993 interaction.deselect();
994 assert_eq!(interaction.sel_drawing, None);
995 }
996}