Skip to main content

egui_charts/drawings/services/
interaction.rs

1//! Drawing interaction service.
2//!
3//! Provides a self-contained handler for drawing interaction: hit testing
4//! against all visible drawings (with timeframe filtering), handle discovery
5//! and hit testing, handle drag lifecycle, and handle rendering.
6//!
7//! This service can be used standalone (without [`DrawingManager`](crate::drawings::DrawingManager))
8//! for custom interaction implementations.
9
10use crate::drawings::{Drawing, DrawingOptions, DrawingToolType, HandlePos};
11use crate::tokens::DESIGN_TOKENS;
12use egui::{Color32, Pos2, Rect, Stroke, epaint::StrokeKind};
13
14/// Self-contained interaction handler for chart drawings.
15///
16/// Manages selection state, handle dragging, and hit testing. Unlike
17/// [`DrawingManager`](crate::drawings::DrawingManager) which coordinates all
18/// services, `DrawingInteraction` focuses purely on pointer-based interaction.
19pub struct DrawingInteraction {
20    /// Currently selected drawing ID, or `None`.
21    pub sel_drawing: Option<usize>,
22    /// Handle currently being dragged: `(drawing_id, handle_position)`.
23    pub dragging_handle: Option<(usize, HandlePos)>,
24    /// Snapshot of the drawing state before the current drag began (for undo).
25    drag_old_state: Option<Drawing>,
26}
27
28impl Default for DrawingInteraction {
29    fn default() -> Self {
30        Self::new()
31    }
32}
33
34impl DrawingInteraction {
35    /// Creates a new interaction handler with no selection and no active drag.
36    pub fn new() -> Self {
37        Self {
38            sel_drawing: None,
39            dragging_handle: None,
40            drag_old_state: None,
41        }
42    }
43
44    /// Clears the current drawing selection.
45    pub fn deselect(&mut self) {
46        self.sel_drawing = None;
47    }
48
49    /// Returns the selection handle positions for a drawing, determined by its
50    /// tool type (see [`HandleService::get_handles`](super::HandleService::get_handles)
51    /// for the canonical implementation).
52    pub fn get_handles(&self, drawing: &Drawing) -> Vec<(HandlePos, Pos2)> {
53        super::HandleService::get_handles(drawing)
54    }
55
56    /// Hit-tests a screen point against the handles of a drawing.
57    ///
58    /// Returns `Some(HandlePos)` if the point is within `handle_size * 1.5`
59    /// pixels of a handle center, `None` otherwise.
60    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    /// Begins a handle drag operation, saving the drawing's current state for undo.
80    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    /// Takes the pre-drag drawing state for undo purposes. Consumes the stored
86    /// snapshot, returning `None` on subsequent calls until a new drag begins.
87    pub fn take_drag_old_state(&mut self) -> Option<Drawing> {
88        self.drag_old_state.take()
89    }
90
91    /// Ends the handle drag operation, returning the pre-drag drawing state
92    /// (for undo) and clearing the drag state.
93    pub fn end_drag_handle(&mut self) -> Option<Drawing> {
94        self.dragging_handle = None;
95        self.drag_old_state.take()
96    }
97
98    /// Returns `true` if a handle drag is currently in progress.
99    pub fn is_dragging(&self) -> bool {
100        self.dragging_handle.is_some()
101    }
102
103    /// Returns the current drag target: `(drawing_id, handle_position)`, or
104    /// `None` if no drag is active.
105    pub fn get_drag_target(&self) -> Option<(usize, HandlePos)> {
106        self.dragging_handle
107    }
108
109    /// Renders selection handles for a drawing as filled squares with borders.
110    ///
111    /// The actively dragged handle is highlighted with [`DrawingOptions::handle_sel_color`].
112    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            // Draw handle as filled square with border
148            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    /// Hit-tests a screen point against all visible drawings, from the top layer down
163    /// (descending z-order, and the later drawing first where the z-order is the same).
164    ///
165    /// Returns the ID of the topmost drawing at `point`, or `None` if no drawing
166    /// was hit. Respects visibility and per-timeframe visibility filtering.
167    /// Uses tool-specific hit testing (line distance, rectangle containment,
168    /// Fibonacci level proximity, etc.) with a 5-pixel tolerance.
169    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            // Skip hidden drawings
174            if !drawing.visible {
175                continue;
176            }
177
178            // Skip drawings not visible on current timeframe
179            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                // Fibonacci tools
207                // The level lines the renderer draws, over the extent it draws them
208                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                    // Extended levels are cut at a box around the anchors and the point, as below.
228                    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                    // Lines that run to the chart's edge are cut at a box around the anchors and
244                    // the point, which holds every part of a line within reach of the point.
245                    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                    // The other tools: proximity to any anchor. A bars pattern draws nothing at its source.
265                    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    /// Hit-tests the shapes painted for one drawing, including text extents and filled zones.
278    ///
279    /// Pass the clipped shapes emitted by the renderer, excluding selection handles. The
280    /// manager records them during `render_all`; standalone hosts can retain their own paint
281    /// commands. Uses a five-pixel tolerance along strokes and respects every shape's clip.
282    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    /// Hit-tests at `point` and selects the topmost drawing found.
289    ///
290    /// Returns `true` if a drawing was selected, `false` if the click hit empty
291    /// space (which deselects the current selection).
292    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
303/// Selection follows the renderer's primitives, so text layout and data-driven geometry
304/// need no second implementation in the interaction service.
305fn 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        // A turned label is hit over its own box, not the larger upright box around it.
378        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
398/// Even-odd containment also handles concave paths. Edges are tested with stroke tolerance.
399fn 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
412/// Whether `point` lies inside the convex polygon `area`, wound either way. An
413/// area with no extent across has no inside: its edges are the only part hit.
414fn 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
428/// Calculates the perpendicular distance from `point` to the closest point on
429/// the line segment `[line_start, line_end]`.
430///
431/// The projection is clamped to the segment endpoints. Returns the Euclidean
432/// distance to `line_start` if the segment is degenerate (zero-length).
433pub 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        // Line is a point
440        return ((point.x - line_start.x).powi(2) + (point.y - line_start.y).powi(2)).sqrt();
441    }
442
443    // Project point onto line
444    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    /// The glyph box is about [0, w] x [0, h] from the text's position. Turned 45 degrees clockwise,
459    /// the upright box around it has its top-right corner at about (w c, 0), with c = cos 45. Turned
460    /// back that is (w / 2, -w / 2), far above the glyphs, so the corner must not hit.
461    #[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        // Point on line
499        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        // Point perpendicular to line
507        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    /// Anchors at y 400 then 100: the 23.6% level is at 100 + 300 * 0.236 = 170.8, or at
516    /// 400 - 300 * 0.236 = 329.2 when reversed.
517    #[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    /// Lines, labels, bands and selection share the configured price on either axis.
537    #[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                // An unlabelled level must not suppress the nearby price label.
576                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    /// Anchors at bars 0 and 10, prices 100 and 400, on x = 30 per bar and y = 500 - price: the
646    /// first anchor is at (0, 400) and the second at (300, 100). A retracement level price is
647    /// 400 - 300 * level, at y = 100 + 300 * level; with `extend_right` its lines run on past
648    /// x = 300. The point (90, 310) selects the construction diagonal between the anchors,
649    /// and 24.6 px from the nearest level (78.6% at 335.8 is 25.8 px away; 61.8% at 285.4).
650    /// A channel with its third anchor at bar 5, price 150, (150, 350), translates by (150,-50):
651    /// level 0.5 runs from (75,375) to (375,75), so y = 450 - x, and
652    /// with `extend_right` on to x = 400 at y = 50. Its drawn levels cross x = 150 between
653    /// y = 250 and 350, far from (150, 214.6), where the 61.8% level of a horizontal channel was.
654    #[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    /// A stored drawing has its chart anchors but no screen points until the next
729    /// `update_screen_coords`; until then no tool is hit, and none panics.
730    #[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    /// Each point is at least 40 px from every anchor. A ray from (0, 0) through (100, 50) runs on
749    /// through (300, 150) and back through (-100, -50) with both extensions on; one from
750    /// (500, 100) through (400, 150) runs left through (200, 250). The cross line through
751    /// (100, 100) runs down through (100, 300). The pattern's second leg runs from
752    /// (100, 100) to (200, 0) through (150, 50). The triangle's edge from (200, 0) to (100, 200)
753    /// passes (150, 100); without a fill its inside is empty. The circle around (100, 100)
754    /// through (150, 100) passes (100, 150), and its centre is empty. The pitchfork's median
755    /// leaves (0, 100) through the midpoint (100, 100) of the other anchors and runs on
756    /// flat. The Gann fan's 1x2 line from (0, 200), half the 1x1 slope to (100, 100), is at
757    /// y = 200 - 300 / 2 = 50 at x = 300. The measure fills its box, which holds (20, 80), 42 px
758    /// from its diagonal. A measure across bars at one price, from (0, 100) to (100, 100), is hit
759    /// 2 px off its border at (50, 102), but not 400 px past its end on the same line. A
760    /// highlighter 16 px wide is hit within 8 px of its line, so a point 7 px off is on it and
761    /// one 9 px off is not. The date range is a box between its anchors, so (150, 400) below it misses.
762    /// The price range holds (120, 65), 15 px from its edges and 30 px from its centre.
763    /// The marker's head at x = 240 spans y = 200 +- 34.3. At x = 200 its shaft spans
764    /// y = 200 +- 14.2, leaving (200, 225) 10.8 px from the shaft and 30 px from the head.
765    /// The ellipse in the box from (0, 0) to (200, 100) is lowest at (100, 100). Every drawing
766    /// extends both ways where its tool can: the trend line from (100, 100) to (200, 150) runs on
767    /// through (500, 300) and (0, 50). A head and shoulders on a neckline at y = 300 fills its
768    /// head above it, which holds (300, 200), and not the gap between shoulder and head at
769    /// (200, 200). The triangle pattern's boundaries y = x / 4 and y = 200 - x / 4 meet at
770    /// (400, 100), so its fill holds (300, 100) beyond the anchors.
771    #[test]
772    fn drawings_are_hit_along_what_they_draw() {
773        use DrawingToolType as T;
774        let interaction = DrawingInteraction::new();
775        // (tool, point, expected hit, anchors)
776        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            // These shapes start filled; their cases test the outline alone.
961            if matches!(tool, T::Triangle | T::Circle | T::Ellipse) {
962                drawing.fill_color = None;
963            }
964            if tool == T::Highlighter {
965                // A 16 px stroke: hit within 8 px.
966                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}