Skip to main content

egui_charts/drawings/manager/
interaction.rs

1//! Pointer interaction: handles and drags, tool selection and drawing creation.
2
3use super::{DrawingManager, DrawingSyncAction};
4use crate::drawings::domain::{
5    ChartPoint, Drawing, DrawingInteractionMode, DrawingToolType, HandlePos,
6};
7use crate::drawings::services::HandleService;
8use crate::model::PostLink;
9use egui::Pos2;
10
11impl DrawingManager {
12    // === Handle delegation ===
13
14    /// Returns the selection handle positions for a drawing (tool-type-aware).
15    /// Regression trends use the fitted endpoints from the last [`Self::render_all`].
16    pub fn get_handles(&self, drawing: &Drawing) -> Vec<(HandlePos, Pos2)> {
17        HandleService::get_handles(&self.handle_drawing(drawing))
18    }
19
20    /// Hit-tests a point against the handles of a drawing.
21    ///
22    /// Returns the [`HandlePos`] if a handle was hit, `None` otherwise.
23    pub fn hit_test_handle(&self, drawing: &Drawing, point: Pos2) -> Option<HandlePos> {
24        if !drawing.selectable
25            || drawing.locked
26            || !drawing.is_visible_on_timeframe(&self.curr_timeframe)
27            || (!self.pane_clips.is_empty()
28                && !self
29                    .pane_clips
30                    .get(&drawing.pane)
31                    .is_some_and(|rect| rect.contains(point)))
32        {
33            return None;
34        }
35        HandleService::hit_test_handle(
36            &self.handle_drawing(drawing),
37            point,
38            self.options.handle_config.size,
39        )
40    }
41
42    /// Begins a handle drag operation, saving the drawing's current state for undo. A locked
43    /// drawing is not dragged.
44    pub fn start_drag_handle(&mut self, drawing_id: usize, handle: HandlePos) {
45        if let Some(drawing) = self.drawings.iter().find(|d| {
46            d.id == drawing_id
47                && d.selectable
48                && !d.locked
49                && d.is_visible_on_timeframe(&self.curr_timeframe)
50        }) {
51            self.dragging_handle = Some((drawing_id, handle));
52            self.drag_old_state = Some(drawing.clone());
53            self.drag_origin = (handle == HandlePos::Middle)
54                .then(|| {
55                    HandleService::get_handles(drawing)
56                        .into_iter()
57                        .find(|(h, _)| *h == handle)
58                        .map(|(_, p)| p)
59                })
60                .flatten();
61        }
62    }
63
64    /// Begins moving the anchors that move with the body (all but a bars pattern's source)
65    /// from a grab at `origin`, retaining the grab offset.
66    /// Locked drawings ignore the grab. The move shares the handle drag's undo and sync lifecycle.
67    pub fn start_drag_body(&mut self, drawing_id: usize, origin: Pos2) {
68        self.start_drag_handle(drawing_id, HandlePos::Middle);
69        if self.dragging_handle == Some((drawing_id, HandlePos::Middle)) {
70            self.drag_origin = Some(origin);
71            self.drag_group = self
72                .drawings
73                .iter()
74                .filter(|d| {
75                    d.id != drawing_id
76                        && self.pane_members.contains(&d.pane)
77                        && d.selectable
78                        && self.selection.is_selected(d.id)
79                        && !d.locked
80                        && d.is_visible_on_timeframe(&self.curr_timeframe)
81                })
82                .cloned()
83                .collect();
84        }
85    }
86
87    pub(crate) fn copy_dragged_drawings(&mut self) {
88        let Some((_, handle)) = self.dragging_handle else {
89            return;
90        };
91        let Some(old) = self.drag_old_state.take() else {
92            return;
93        };
94        let primary_id = old.id;
95        let mut copies = std::mem::take(&mut self.drag_group);
96        copies.push(old);
97        copies.sort_by_key(|copy| {
98            (
99                copy.z_order,
100                self.drawings.iter().position(|d| d.id == copy.id),
101            )
102        });
103        // A copy that could not be created ends the gesture, as a grab of a locked drawing does.
104        if copies
105            .iter()
106            .any(|copy| self.validate_created(copy).is_err())
107        {
108            self.dragging_handle = None;
109            self.drag_origin = None;
110            return;
111        }
112        let primary = copies.iter().position(|d| d.id == primary_id).unwrap();
113        for copy in &mut copies {
114            copy.id = self.take_id();
115            copy.stored_id = None;
116            copy.created_at = None;
117            copy.z_order = self.top_z_order();
118            self.drawings.push(copy.clone());
119        }
120        let old = copies.remove(primary);
121        self.dragging_handle = Some((old.id, handle));
122        self.drag_old_state = Some(old.clone());
123        self.drag_group = copies;
124        self.select_drawings(
125            self.drag_group
126                .iter()
127                .map(|d| d.id)
128                .chain([old.id])
129                .collect::<Vec<_>>(),
130        );
131        self.drag_copy = true;
132    }
133
134    pub(crate) fn constrains_angle(tool: DrawingToolType) -> bool {
135        use DrawingToolType as T;
136        matches!(
137            tool,
138            T::TrendLine
139                | T::Ray
140                | T::ExtendedLine
141                | T::InfoLine
142                | T::TrendAngle
143                | T::Arrow
144                | T::ParallelChannel
145                | T::DisjointChannel
146                | T::FlatTopBottom
147                | T::RegressionTrend
148                | T::FibonacciChannel
149        )
150    }
151
152    // Project onto the nearest 45-degree direction. Integer directions keep diagonals exact.
153    fn constrain_angle(&self, drawing: &Drawing, index: usize, point: Pos2) -> Pos2 {
154        if !self.interaction_modifiers.shift
155            || drawing.custom_tool.is_some()
156            || !Self::constrains_angle(drawing.tool_type)
157        {
158            return point;
159        }
160        let anchor_index = if index == 0 { 1 } else { index - 1 };
161        let Some(&anchor) = drawing.points.get(anchor_index) else {
162            return point;
163        };
164        let delta = point - anchor;
165        let angle =
166            (delta.angle() / std::f32::consts::FRAC_PI_4).round() * std::f32::consts::FRAC_PI_4;
167        let direction = egui::vec2(angle.cos().round(), angle.sin().round());
168        let mut offset = direction * (delta.dot(direction) / direction.length_sq());
169        // Clip along the ray so an off-pane anchor keeps the same angle at the pane edge.
170        if self.pane_rect.is_positive() {
171            let mut first = 0.0_f32;
172            let mut last = f32::INFINITY;
173            for (start, step, min, max) in [
174                (
175                    anchor.x,
176                    direction.x,
177                    self.pane_rect.left(),
178                    self.pane_rect.right(),
179                ),
180                (
181                    anchor.y,
182                    direction.y,
183                    self.pane_rect.top(),
184                    self.pane_rect.bottom(),
185                ),
186            ] {
187                if step == 0.0 {
188                    if start < min || start > max {
189                        last = f32::NEG_INFINITY;
190                    }
191                } else {
192                    let a = (min - start) / step;
193                    let b = (max - start) / step;
194                    first = first.max(a.min(b));
195                    last = last.min(a.max(b));
196                }
197            }
198            if first <= last {
199                let distance = delta.dot(direction) / direction.length_sq();
200                offset = direction * distance.clamp(first, last);
201            }
202        }
203        anchor + offset
204    }
205
206    fn constrain_preview(&self, point: Pos2) -> Pos2 {
207        self.curr_drawing.as_ref().map_or(point, |drawing| {
208            let index = drawing
209                .points
210                .len()
211                .saturating_sub(usize::from(self.preview_point));
212            self.constrain_angle(drawing, index, point)
213        })
214    }
215
216    /// Updates the position of the currently dragged handle to `new_pos`,
217    /// applying snap behavior and coordinate conversion.
218    /// `translate_price(price, from_y, to_y)` shifts whole-drawing prices in scale units;
219    /// `y_to_price` retains the pointer bounds for individual handles.
220    pub fn update_drag_handle<F, G, H>(
221        &mut self,
222        new_pos: Pos2,
223        x_to_bar: F,
224        y_to_price: G,
225        translate_price: H,
226    ) where
227        F: Fn(f32) -> f32,
228        G: Fn(f32) -> f64,
229        H: Fn(f64, f32, f32) -> f64,
230    {
231        if let Some((id, handle)) = self.dragging_handle {
232            // A fitted end can be rounded or clamped away from its stored anchor.
233            if let Some(drawing) = self.drawings.iter().find(|d| d.id == id)
234                && drawing.tool_type == DrawingToolType::RegressionTrend
235                && self
236                    .get_handles(drawing)
237                    .into_iter()
238                    .any(|(h, point)| h == handle && x_to_bar(new_pos.x) == x_to_bar(point.x))
239            {
240                return;
241            }
242            // Compute snapped position before mutable borrow
243            let mut snapped = self.snap_point(new_pos, &self.drawings);
244            let mut constrained_price = None;
245            if self.interaction_modifiers.shift
246                && let Some(origin) = self.drag_origin
247            {
248                let delta = snapped - origin;
249                if delta.x.abs() >= delta.y.abs() {
250                    snapped.y = origin.y;
251                } else {
252                    snapped.x = origin.x;
253                }
254            } else if let Some(drawing) = self.drawings.iter().find(|d| d.id == id) {
255                let index = match handle {
256                    HandlePos::Start => Some(0),
257                    HandlePos::End => Some(1),
258                    HandlePos::Point(index) => Some(index),
259                    _ => None,
260                };
261                if let Some(index) = index {
262                    snapped = self.constrain_angle(drawing, index, snapped);
263                    let anchor_index = if index == 0 { 1 } else { index - 1 };
264                    if self.interaction_modifiers.shift
265                        && Self::constrains_angle(drawing.tool_type)
266                        && drawing.custom_tool.is_none()
267                        && let Some((anchor, chart_anchor)) = drawing
268                            .points
269                            .get(anchor_index)
270                            .zip(drawing.chart_points.get(anchor_index))
271                    {
272                        // Use the fixed anchor's current projection, including after a view change.
273                        constrained_price = Some((chart_anchor.price, anchor.y));
274                    }
275                }
276            }
277            // Shift edits receive precise bars for the angle; other Shift edits keep whole bars.
278            let whole_bars = self.interaction_modifiers.shift && constrained_price.is_none();
279            let x_to_bar = |x| {
280                let bar = x_to_bar(x);
281                if whole_bars { bar.round() } else { bar }
282            };
283            if let Some(origin) = self.drag_origin {
284                let bars = x_to_bar(snapped.x) - x_to_bar(origin.x);
285                for old in &self.drag_group {
286                    if let Some(drawing) = self
287                        .drawings
288                        .iter_mut()
289                        .find(|d| d.id == old.id && !d.locked)
290                    {
291                        let first = old.first_moved_anchor();
292                        for (point, before) in drawing
293                            .chart_points
294                            .iter_mut()
295                            .zip(&old.chart_points)
296                            .skip(first)
297                        {
298                            *point = ChartPoint::new(
299                                before.bar_idx + bars,
300                                translate_price(before.price, origin.y, snapped.y),
301                            );
302                        }
303                        for (point, before) in
304                            drawing.points.iter_mut().zip(&old.points).skip(first)
305                        {
306                            *point = *before + (snapped - origin);
307                        }
308                        if let Some([x, y]) = old.screen_anchor {
309                            drawing.screen_anchor = Some([
310                                x + (snapped.x - origin.x) / self.pane_rect.width(),
311                                y + (snapped.y - origin.y) / self.pane_rect.height(),
312                            ]);
313                        }
314                    }
315                }
316            }
317            if let Some(drawing) = self.drawings.iter_mut().find(|d| d.id == id && !d.locked) {
318                if let (Some(origin), Some(old)) = (self.drag_origin, &self.drag_old_state) {
319                    let bars = x_to_bar(snapped.x) - x_to_bar(origin.x);
320                    // Dragging a label leaves the point it annotates; a selection, a copy or a
321                    // note saved without a label anchor moves whole.
322                    let skip = usize::from(
323                        self.drag_group.is_empty()
324                            && !self.drag_copy
325                            && old.points.len() > 1
326                            && matches!(
327                                drawing.tool_type,
328                                DrawingToolType::Callout
329                                    | DrawingToolType::Note
330                                    | DrawingToolType::PriceNote
331                            ),
332                    );
333                    let skip = skip.max(old.first_moved_anchor());
334                    for (point, before) in drawing
335                        .chart_points
336                        .iter_mut()
337                        .zip(&old.chart_points)
338                        .skip(skip)
339                    {
340                        *point = ChartPoint::new(
341                            before.bar_idx + bars,
342                            translate_price(before.price, origin.y, snapped.y),
343                        );
344                    }
345                    for (point, before) in drawing.points.iter_mut().zip(&old.points).skip(skip) {
346                        *point = *before + (snapped - origin);
347                    }
348                } else {
349                    HandleService::update_handle(
350                        drawing,
351                        handle,
352                        snapped,
353                        x_to_bar,
354                        |y| {
355                            constrained_price.map_or_else(
356                                || y_to_price(y),
357                                |(price, from)| translate_price(price, from, y),
358                            )
359                        },
360                        &translate_price,
361                    );
362                }
363                if drawing.screen_anchor.is_some()
364                    && let Some(pos) = drawing.points.first()
365                {
366                    drawing.screen_anchor = Some([
367                        (pos.x - self.pane_rect.left()) / self.pane_rect.width(),
368                        (pos.y - self.pane_rect.top()) / self.pane_rect.height(),
369                    ]);
370                }
371            }
372        }
373    }
374
375    /// Puts each pin of this pane that still carries a pane position on the bar and price under
376    /// that position, and clears the position, so pins saved by earlier releases stay where they
377    /// were shown. Called by the chart after the pane's screen coordinates are updated.
378    pub(crate) fn anchor_pins_to_bars(
379        &mut self,
380        x_to_bar: impl Fn(f32) -> f32,
381        y_to_price: impl Fn(f32) -> f64,
382    ) {
383        let pane = self.pane_rect;
384        if !pane.is_finite() || pane.width() <= 0.0 || pane.height() <= 0.0 {
385            return;
386        }
387        let mut converted = Vec::new();
388        for drawing in self.drawings.iter_mut().chain(&mut self.curr_drawing) {
389            let Some([x, y]) = drawing.screen_anchor else {
390                continue;
391            };
392            if drawing.tool_type != DrawingToolType::AnchoredNote
393                || !self.pane_members.contains(&drawing.pane)
394            {
395                continue;
396            }
397            let pos = pane.min + egui::vec2(x * pane.width(), y * pane.height());
398            let anchor = ChartPoint::new(x_to_bar(pos.x), y_to_price(pos.y));
399            if let (Some(point), Some(chart_point), true) = (
400                drawing.points.first_mut(),
401                drawing.chart_points.first_mut(),
402                anchor.bar_idx.is_finite() && anchor.price.is_finite(),
403            ) {
404                *point = pos;
405                *chart_point = anchor;
406                drawing.screen_anchor = None;
407                converted.push(drawing.id);
408            }
409        }
410        for id in converted {
411            self.queue_update(id);
412        }
413    }
414
415    /// Ends the current handle drag or [`Self::drag_edit_drawing`] gesture, pushing one modify
416    /// command to the undo history and queuing one update for the whole gesture.
417    /// A copy drag records the additions instead, so undo removes every copy.
418    pub fn end_drag_handle(&mut self) {
419        if let Some(old_state) = self.drag_old_state.take() {
420            if std::mem::take(&mut self.drag_copy) {
421                let mut ids: Vec<_> = std::mem::take(&mut self.drag_group)
422                    .iter()
423                    .map(|d| d.id)
424                    .collect();
425                ids.push(old_state.id);
426                ids.sort_by_key(|id| self.drawings.iter().position(|d| d.id == *id));
427                for &id in &ids {
428                    self.queue(DrawingSyncAction::SaveDrawing(id));
429                }
430                self.history
431                    .push_command(crate::drawings::services::DrawingCommand::Snapshot {
432                        drawings: Vec::new(),
433                        ids,
434                    });
435                self.dragging_handle = None;
436                self.drag_origin = None;
437                return;
438            }
439            self.queue_edit(&old_state);
440            if self.drag_group.is_empty() {
441                self.history.push_modify(old_state.id, old_state);
442            } else {
443                let mut before = std::mem::take(&mut self.drag_group);
444                for drawing in &before {
445                    self.queue_update(drawing.id);
446                }
447                before.push(old_state);
448                let snapshot = self
449                    .drawings
450                    .iter()
451                    .enumerate()
452                    .filter_map(|(index, drawing)| {
453                        let old = before.iter().find(|old| old.id == drawing.id)?;
454                        Some((index, old.clone()))
455                    })
456                    .collect();
457                self.history.push_snapshot(snapshot);
458            }
459        }
460        self.dragging_handle = None;
461        self.drag_origin = None;
462    }
463
464    // === Tool management ===
465
466    /// Sets the active drawing tool and returns whether it is now active. Pass `None` to exit
467    /// drawing mode and return to selection/pointer mode. A tool cancels any in-progress
468    /// drawing and leaves eraser mode. A built-in tool the [`registry`](Self::registry)
469    /// disables is not set: the manager returns to selection mode, as with `None`, and the
470    /// call returns `false`.
471    pub fn set_active_tool(&mut self, tool: Option<DrawingToolType>) -> bool {
472        self.active_custom_tool = None;
473        let set = tool.filter(|tool| self.registry.availability(*tool).enabled);
474        self.active_tool = set;
475        self.cancel_curr_drawing();
476        if set.is_some() {
477            self.eraser_mode = false;
478        }
479        set == tool
480    }
481
482    /// The registered tool selected for the next placement, or `None` for a built-in tool.
483    pub fn active_custom_tool(&self) -> Option<&str> {
484        self.active_custom_tool.as_deref()
485    }
486
487    /// The built-in tool the next pointer placement uses, or `None` in selection mode. A
488    /// registered tool is active as [`DrawingToolType::Polyline`] with its id in
489    /// [`Self::active_custom_tool`]. Set it with [`Self::set_active_tool`] or
490    /// [`Self::set_custom_tool`].
491    pub fn active_tool(&self) -> Option<DrawingToolType> {
492        self.active_tool
493    }
494
495    /// Whether a click deletes the drawing under the pointer instead of selecting it. Switch
496    /// it with [`Self::set_eraser_mode`].
497    pub fn is_eraser_mode(&self) -> bool {
498        self.eraser_mode
499    }
500
501    /// The drawing being placed. It joins [`Self::drawings`] when completed, except for the
502    /// temporary [`DrawingToolType::Measure`] ruler.
503    pub fn curr_drawing(&self) -> Option<&Drawing> {
504        self.curr_drawing.as_ref()
505    }
506
507    /// The drawing being placed, to adjust its settings before it is completed, such as the
508    /// glyph size of an icon that [`Self::start_icon_insertion`] prepared.
509    pub fn curr_drawing_mut(&mut self) -> Option<&mut Drawing> {
510        self.curr_drawing.as_mut()
511    }
512
513    /// The drawing and handle being dragged, as `(drawing_id, handle)`, from
514    /// [`Self::start_drag_handle`] or [`Self::start_drag_body`] until [`Self::end_drag_handle`].
515    pub fn dragging_handle(&self) -> Option<(usize, HandlePos)> {
516        self.dragging_handle
517    }
518
519    /// Starts a registered tool's multi-click construction. One-anchor tools complete on the
520    /// first click. The host picker uses `registry.custom_tools()` for its ids and names.
521    pub fn set_custom_tool(&mut self, id: &str) -> Result<(), &'static str> {
522        if self.registry.get(id).is_none() {
523            return Err("Unknown drawing tool");
524        }
525        self.cancel_curr_drawing();
526        self.eraser_mode = false;
527        self.active_tool = Some(DrawingToolType::Polyline);
528        self.active_custom_tool = Some(id.into());
529        Ok(())
530    }
531
532    /// Shows a registered tool's settings and records a changed draft as one undoable edit.
533    /// Unknown tools and locked drawings show no editor and return false.
534    pub fn show_custom_settings(&mut self, ui: &mut egui::Ui, id: usize) -> bool {
535        let Some(drawing) = self.drawings.iter().find(|d| d.id == id && !d.locked) else {
536            return false;
537        };
538        let Some(tool) = drawing
539            .custom_tool
540            .as_deref()
541            .and_then(|id| self.registry.get(id))
542        else {
543            return false;
544        };
545        let mut settings = drawing.custom_settings.clone();
546        if !tool.implementation.settings(ui, &mut settings) || settings == drawing.custom_settings {
547            return false;
548        }
549        self.edit_drawing(id, |drawing| drawing.custom_settings = settings);
550        true
551    }
552
553    /// Switches eraser mode on or off. In eraser mode a click on the chart deletes the
554    /// drawing under the pointer, unless it is locked. Switching it on leaves the active tool
555    /// and cancels any in-progress drawing; choosing a tool with [`Self::set_active_tool`]
556    /// switches it off.
557    pub fn set_eraser_mode(&mut self, on: bool) {
558        self.eraser_mode = on;
559        if on {
560            self.active_custom_tool = None;
561            self.active_tool = None;
562            self.cancel_curr_drawing();
563        }
564    }
565
566    /// Start text annotation mode
567    ///
568    /// Activates the Note drawing tool, allowing the user to click the bar and price to
569    /// annotate, then where the label goes.
570    pub fn start_text_annotation(&mut self) {
571        self.set_active_tool(Some(DrawingToolType::Note));
572    }
573
574    /// Start icon/emoji insertion mode
575    ///
576    /// Prepares a bundled icon, Unicode emoji or `sticker:` glyph,
577    /// allowing the user to click on the chart to place it.
578    pub fn start_icon_insertion(&mut self, icon_name: String) {
579        let tool = if icon_name.starts_with("sticker:") {
580            DrawingToolType::Sticker
581        } else {
582            DrawingToolType::FontIcon
583        };
584        if !self.registry.availability(tool).enabled {
585            return;
586        }
587        self.set_active_tool(Some(tool));
588
589        // Create a new drawing with the icon name
590        let id = self.take_id();
591        let mut drawing = self.new_drawing(id, tool);
592        drawing.text = Some(icon_name);
593        drawing.completed = false; // Not complete until user clicks
594
595        // Set as current drawing (will be placed on next click)
596        self.curr_drawing = Some(drawing);
597    }
598
599    /// Prepares a post or idea card showing `post`, placed by the next click on the chart, as
600    /// [`Self::start_icon_insertion`] does for a picked icon. The tool's saved defaults apply,
601    /// and the inline editor does not open, since the card has its content. Does nothing for
602    /// other tools or a disabled one.
603    pub fn start_post_insertion(&mut self, tool: DrawingToolType, post: PostLink) {
604        if !matches!(tool, DrawingToolType::Tweet | DrawingToolType::Idea)
605            || !self.registry.availability(tool).enabled
606        {
607            return;
608        }
609        self.set_active_tool(Some(tool));
610        let id = self.take_id();
611        let mut drawing = self.new_drawing(id, tool);
612        drawing.post = Some(post);
613        drawing.completed = false;
614        self.curr_drawing = Some(drawing);
615    }
616
617    // === Drawing lifecycle ===
618
619    /// `point` with the magnet applied, except for freehand samples, which follow the pointer.
620    fn snap_placement(&self, tool: Option<DrawingToolType>, point: Pos2) -> Pos2 {
621        if tool
622            .is_some_and(|tool| tool.interaction_mode() == DrawingInteractionMode::ContinuousDraw)
623        {
624            point
625        } else {
626            self.snap_point(point, &self.drawings)
627        }
628    }
629
630    /// Starts a new drawing of the given tool type at the given screen position, or places the
631    /// drawing [`Self::start_icon_insertion`] prepared for it. Does nothing for a cursor tool
632    /// or a disabled built-in tool. A completed drawing that saving would refuse, such as one
633    /// whose anchor maps to a non-finite position, is dropped.
634    ///
635    /// The coordinate conversion closures (`x_to_bar`, `y_to_price`) convert
636    /// screen coordinates to chart coordinates for persistent storage. If the
637    /// tool requires only one point, the drawing is immediately completed and
638    /// added to the drawing list.
639    pub fn start_drawing_with_coords<F, G>(
640        &mut self,
641        tool_type: DrawingToolType,
642        point: Pos2,
643        x_to_bar: F,
644        y_to_price: G,
645    ) where
646        F: Fn(f32) -> f32,
647        G: Fn(f32) -> f64,
648    {
649        if tool_type.is_cursor()
650            || (self.active_custom_tool.is_none() && !self.registry.availability(tool_type).enabled)
651        {
652            return;
653        }
654        let point = self.snap_placement(Some(tool_type), point);
655        // A drawing prepared before its first anchor, such as a picked icon, starts here.
656        let mut drawing = match self.curr_drawing.take() {
657            Some(prepared) if prepared.tool_type == tool_type && prepared.points.is_empty() => {
658                prepared
659            }
660            _ => {
661                let id = self.take_id();
662                self.new_drawing(id, tool_type)
663            }
664        };
665        if let Some(id) = &self.active_custom_tool {
666            drawing.custom_tool = Some(id.clone());
667        }
668        drawing.pane = self.pane;
669        drawing.z_order = self.top_z_order();
670        self.preview_point = false;
671        drawing.add_point_with_chart_coords(point, &x_to_bar, &y_to_price);
672        if matches!(
673            tool_type,
674            DrawingToolType::LongPos | DrawingToolType::ShortPos
675        ) {
676            let side = if tool_type == DrawingToolType::LongPos {
677                -1.0
678            } else {
679                1.0
680            };
681            let target = point + egui::vec2(100.0, side * 50.0);
682            let target_price = y_to_price(target.y);
683            let entry_price = drawing.chart_points[0].price;
684            let stop = Pos2::new(target.x, point.y - side * 50.0);
685            drawing.points.extend([target, stop]);
686            drawing.chart_points.extend([
687                ChartPoint::new(x_to_bar(target.x), target_price),
688                ChartPoint::new(x_to_bar(target.x), 2.0 * entry_price - target_price),
689            ]);
690        }
691
692        if let Some(tool) = drawing
693            .custom_tool
694            .as_deref()
695            .and_then(|id| self.registry.get(id))
696        {
697            drawing.completed = drawing.chart_points.len() == tool.anchors;
698        }
699        let completed = drawing.completed;
700        self.curr_drawing = Some(drawing);
701        if completed {
702            self.finish_curr_drawing();
703        }
704    }
705
706    /// Adds a subsequent point to the in-progress drawing.
707    ///
708    /// If the added point completes the drawing (based on
709    /// [`DrawingToolType::required_points`]), the drawing is finalized, pushed
710    /// to the drawing list, and a cloud sync action is queued.
711    pub fn add_point_with_coords<F, G>(&mut self, point: Pos2, x_to_bar: F, y_to_price: G)
712    where
713        F: Fn(f32) -> f32,
714        G: Fn(f32) -> f64,
715    {
716        let point = self.constrain_preview(
717            self.snap_placement(self.curr_drawing.as_ref().map(|d| d.tool_type), point),
718        );
719        if let Some(ref mut drawing) = self.curr_drawing {
720            // The click takes the place of the pointer preview.
721            if std::mem::take(&mut self.preview_point) {
722                drawing.points.pop();
723                drawing.chart_points.pop();
724            }
725            drawing.add_point_with_chart_coords(point, &x_to_bar, &y_to_price);
726            drawing.align_second_anchor();
727            if drawing.completed {
728                drawing.store_default_curve_controls(&x_to_bar, &y_to_price);
729            }
730            if let Some(tool) = drawing
731                .custom_tool
732                .as_deref()
733                .and_then(|id| self.registry.get(id))
734            {
735                drawing.completed = drawing.chart_points.len() == tool.anchors;
736            }
737            if drawing.completed {
738                self.finish_curr_drawing();
739            }
740        }
741    }
742
743    /// Moves the pointer preview of the in-progress drawing to `point`, adding it after the
744    /// placed anchors if there is none yet. The next click or the end of a drag replaces it
745    /// with an anchor; finishing a multi-point drawing drops it.
746    pub fn update_last_point_with_coords<F, G>(&mut self, point: Pos2, x_to_bar: F, y_to_price: G)
747    where
748        F: Fn(f32) -> f32,
749        G: Fn(f32) -> f64,
750    {
751        let point = self.constrain_preview(
752            self.snap_placement(self.curr_drawing.as_ref().map(|d| d.tool_type), point),
753        );
754        if let Some(ref mut drawing) = self.curr_drawing
755            && !drawing.points.is_empty()
756            && !drawing.completed
757        {
758            if self.preview_point {
759                drawing.update_last_point_with_chart_coords(point, x_to_bar, y_to_price);
760            } else {
761                drawing.add_point_with_chart_coords(point, &x_to_bar, &y_to_price);
762                drawing.completed = false;
763                self.preview_point = true;
764            }
765            drawing.align_second_anchor();
766        }
767    }
768
769    /// Manually completes the in-progress drawing (e.g., on double-click or Enter
770    /// for multi-point tools), without its pointer preview. Requires at least 2 points, and
771    /// as many as the tool draws with, so that what it adds can be saved. A drawing that saving
772    /// would still refuse is dropped, and the tool stays active.
773    pub fn complete_curr_drawing(&mut self) {
774        if let Some(drawing) = &mut self.curr_drawing
775            && std::mem::take(&mut self.preview_point)
776        {
777            drawing.points.pop();
778            drawing.chart_points.pop();
779        }
780        if let Some(ref mut drawing) = self.curr_drawing
781            && drawing.custom_tool.as_deref().map_or_else(
782                || drawing.points.len() >= drawing.tool_type.anchors_needed().max(2),
783                |id| {
784                    self.registry
785                        .get(id)
786                        .is_some_and(|tool| drawing.points.len() == tool.anchors)
787                },
788            )
789        {
790            drawing.completed = true;
791            self.finish_curr_drawing();
792        }
793    }
794
795    /// Closes the built-in polyline being placed when `point` is within reach of its first
796    /// anchor's handle and at least three anchors are placed. Returns whether it finished.
797    pub(crate) fn close_curr_polyline(&mut self, point: Pos2) -> bool {
798        let reach = self.options.handle_config.size * 1.5;
799        let preview = usize::from(self.preview_point);
800        let Some(drawing) = self.curr_drawing.as_mut().filter(|d| {
801            d.tool_type == DrawingToolType::Polyline
802                && d.custom_tool.is_none()
803                && d.points.len().saturating_sub(preview) >= 3
804                && d.points[0].distance(point) <= reach
805        }) else {
806            return false;
807        };
808        drawing.closed = true;
809        self.complete_curr_drawing();
810        true
811    }
812
813    /// Moves the completed current drawing to the drawing list and queues it to be saved, or
814    /// keeps the measurement until the next click. Drops a drawing when its tool was disabled
815    /// meanwhile or saving would refuse it.
816    /// In keep-drawing mode the icon tool goes on placing the icon that was picked.
817    fn finish_curr_drawing(&mut self) {
818        if let Some(mut drawing) = self.curr_drawing.take() {
819            // A bars pattern's copy starts over its source: on the left source bar, at the
820            // first anchor's price. Moving the pattern then moves only this anchor.
821            if drawing.tool_type == DrawingToolType::BarsPattern
822                && let (&[first, second], &[first_at, second_at]) =
823                    (drawing.chart_points.as_slice(), drawing.points.as_slice())
824            {
825                let (bar, x) = if second.bar_idx < first.bar_idx {
826                    (second.bar_idx, second_at.x)
827                } else {
828                    (first.bar_idx, first_at.x)
829                };
830                drawing.chart_points.push(ChartPoint::new(bar, first.price));
831                drawing.points.push(Pos2::new(x, first_at.y));
832            }
833            drawing.update_pane_coords(self.pane_rect);
834            if self.validate_created(&drawing).is_err() {
835                return;
836            }
837            // A measurement is a ruler for the moment: kept until the next click, never stored.
838            if drawing.tool_type == DrawingToolType::Measure {
839                self.measurement = Some(drawing);
840                self.active_tool = None;
841                return;
842            }
843            let icon = matches!(
844                drawing.tool_type,
845                DrawingToolType::FontIcon | DrawingToolType::Sticker
846            )
847            .then(|| drawing.text.clone())
848            .flatten();
849            let glyph_size = drawing.font_size;
850            self.history.push_add(drawing.clone());
851            let id = drawing.id;
852            self.drawings.push(drawing);
853            self.select(id);
854            self.queue(DrawingSyncAction::SaveDrawing(id));
855            if !self.stay_in_drawing_mode {
856                self.active_tool = None;
857                self.active_custom_tool = None;
858            } else if let Some(icon) = icon {
859                self.start_icon_insertion(icon);
860                if let Some(prepared) = &mut self.curr_drawing {
861                    prepared.font_size = glyph_size;
862                }
863            }
864        }
865    }
866
867    /// Cancels the in-progress drawing without adding it to the drawing list,
868    /// and clears a finished measurement.
869    pub fn cancel_curr_drawing(&mut self) {
870        self.curr_drawing = None;
871        self.preview_point = false;
872        self.measurement = None;
873    }
874
875    // === Compatibility methods for old DrawingManager API ===
876
877    /// Complete a drag-to-draw drawing (see [`Self::complete_curr_drawing`])
878    pub fn complete_drag_drawing(&mut self) {
879        // The pointer preview is where the drag ended: the drawing's last anchor.
880        self.preview_point = false;
881        // A position's stop starts mirrored around the entry, as an anchor of
882        // its own so it can be moved without moving the target.
883        if let Some(drawing) = &mut self.curr_drawing
884            && matches!(
885                drawing.tool_type,
886                DrawingToolType::LongPos | DrawingToolType::ShortPos
887            )
888            && let (&[entry, target], &[entry_at, target_at]) =
889                (drawing.chart_points.as_slice(), drawing.points.as_slice())
890        {
891            let stop = 2.0 * entry.price - target.price;
892            drawing
893                .chart_points
894                .push(ChartPoint::new(target.bar_idx, stop));
895            drawing
896                .points
897                .push(Pos2::new(target_at.x, 2.0 * entry_at.y - target_at.y));
898        }
899        self.complete_curr_drawing();
900    }
901
902    /// Alias for [`Self::update_drag_handle`], with the same pointer mapping and price translation.
903    pub fn update_drag_handle_with_coords<F, G, H>(
904        &mut self,
905        new_pos: Pos2,
906        x_to_bar: F,
907        y_to_price: G,
908        translate_price: H,
909    ) where
910        F: Fn(f32) -> f32,
911        G: Fn(f32) -> f64,
912        H: Fn(f64, f32, f32) -> f64,
913    {
914        self.update_drag_handle(new_pos, x_to_bar, y_to_price, translate_price);
915    }
916
917    /// Hit test a handle by drawing ID
918    /// Returns the handle position if hit, None otherwise
919    pub fn hit_test_handle_by_id(&self, point: Pos2, drawing_id: usize) -> Option<HandlePos> {
920        if let Some(drawing) = self.drawings.iter().find(|d| d.id == drawing_id) {
921            self.hit_test_handle(drawing, point)
922        } else {
923            None
924        }
925    }
926}
927
928#[cfg(test)]
929mod tests {
930    use super::*;
931
932    /// Two clicks copy the bars between them in place, right to left as well, and dragging the
933    /// pattern moves its copy while the source anchors stay on the bars it copies.
934    #[test]
935    fn a_bars_pattern_is_copied_on_two_clicks_and_moved_by_its_copy() {
936        let mut manager = DrawingManager::new();
937        let x_to_bar = |x: f32| x / 10.0;
938        let y_to_price = |y: f32| 1000.0 - f64::from(y);
939        manager.start_drawing_with_coords(
940            DrawingToolType::BarsPattern,
941            Pos2::new(300.0, 200.0),
942            x_to_bar,
943            y_to_price,
944        );
945        manager.add_point_with_coords(Pos2::new(100.0, 400.0), x_to_bar, y_to_price);
946        assert_eq!(manager.drawings.len(), 1);
947        let drawing = &manager.drawings[0];
948        assert_eq!(
949            drawing.chart_points,
950            [
951                ChartPoint::new(30.0, 800.0),
952                ChartPoint::new(10.0, 600.0),
953                ChartPoint::new(10.0, 800.0)
954            ]
955        );
956        assert_eq!(drawing.points[2], Pos2::new(100.0, 200.0));
957        let id = drawing.id;
958        manager.start_drag_body(id, Pos2::new(150.0, 250.0));
959        manager.update_drag_handle(
960            Pos2::new(250.0, 150.0),
961            x_to_bar,
962            y_to_price,
963            |price, from, to| price - f64::from(to - from),
964        );
965        manager.end_drag_handle();
966        assert_eq!(
967            manager.drawings[0].chart_points,
968            [
969                ChartPoint::new(30.0, 800.0),
970                ChartPoint::new(10.0, 600.0),
971                ChartPoint::new(20.0, 900.0)
972            ]
973        );
974    }
975
976    #[test]
977    fn copied_groups_keep_their_layers_locally_and_when_synced() {
978        fn top_tool(manager: &DrawingManager) -> DrawingToolType {
979            let id = manager.hit_test(Pos2::new(75.0, 100.0)).unwrap();
980            manager
981                .drawings
982                .iter()
983                .find(|d| d.id == id)
984                .unwrap()
985                .tool_type
986        }
987        for (background_layer, line_layer, expected) in [
988            (2, 5, DrawingToolType::TrendLine),
989            (5, 5, DrawingToolType::TrendLine),
990            (5, 2, DrawingToolType::Rect),
991        ] {
992            let mut manager = DrawingManager::new();
993            for (id, tool, layer, points) in [
994                (
995                    7,
996                    DrawingToolType::Rect,
997                    background_layer,
998                    [Pos2::new(50.0, 50.0), Pos2::new(150.0, 150.0)],
999                ),
1000                (
1001                    8,
1002                    DrawingToolType::TrendLine,
1003                    line_layer,
1004                    [Pos2::new(50.0, 100.0), Pos2::new(150.0, 100.0)],
1005                ),
1006            ] {
1007                let mut drawing = Drawing::new(id, tool);
1008                drawing.z_order = layer;
1009                drawing.completed = true;
1010                drawing.fill_color = Some([30, 60, 95, 255]);
1011                drawing.points = points.to_vec();
1012                drawing.chart_points = points
1013                    .iter()
1014                    .map(|p| ChartPoint::new(p.x / 10.0, 200.0 - f64::from(p.y)))
1015                    .collect();
1016                manager.drawings.push(drawing);
1017            }
1018            manager.select_drawings([7, 8]);
1019            manager.start_drag_body(7, Pos2::new(75.0, 75.0));
1020            manager.copy_dragged_drawings();
1021            manager.end_drag_handle();
1022            manager.deselect();
1023            assert_eq!(manager.drawings.len(), 4);
1024            assert_eq!(top_tool(&manager), expected);
1025            let mut peer = DrawingManager::new();
1026            for action in manager.pending_sync.lock().unwrap().iter() {
1027                if let DrawingSyncAction::SaveDrawing(id) = action {
1028                    let mut copy = manager
1029                        .drawings
1030                        .iter()
1031                        .find(|d| d.id == *id)
1032                        .unwrap()
1033                        .clone();
1034                    copy.stored_id = Some(id.to_string());
1035                    peer.add_synced_drawing(copy).unwrap();
1036                }
1037            }
1038            peer.update_all_screen_coords(|bar| bar * 10.0, |price| (200.0 - price) as f32);
1039            assert_eq!(peer.drawings.len(), 2);
1040            assert_eq!(top_tool(&peer), expected);
1041            assert!(manager.undo());
1042            assert_eq!(manager.drawings.len(), 2);
1043            assert!(!manager.undo());
1044            assert!(manager.redo());
1045            assert_eq!(manager.drawings.len(), 4);
1046            manager.update_all_screen_coords(|bar| bar * 10.0, |price| (200.0 - price) as f32);
1047            assert_eq!(top_tool(&manager), expected);
1048        }
1049    }
1050
1051    #[test]
1052    fn copy_drag_ignores_non_pointer_edits() {
1053        let mut manager = DrawingManager::new();
1054        manager
1055            .drawings
1056            .push(Drawing::new(7, DrawingToolType::TrendLine));
1057        manager.drag_edit_drawing(7, |drawing| drawing.text = Some("Draft".into()));
1058        manager.copy_dragged_drawings();
1059        assert_eq!(manager.drawings.len(), 1);
1060        manager.end_drag_handle();
1061        assert!(manager.undo());
1062        assert_eq!(manager.drawings[0].text, None);
1063        assert!(!manager.undo());
1064    }
1065
1066    /// Dragging the label of a tool that annotates a point leaves that point on its bar and price,
1067    /// in one undo step. Other drawings, a selection and a copy move whole.
1068    #[test]
1069    fn annotation_boxes_move_without_their_anchor() {
1070        let x_to_bar = |x: f32| x / 10.0;
1071        let y_to_price = |y: f32| 1000.0 - f64::from(y);
1072        let translate = |price: f64, from: f32, to: f32| price - f64::from(to - from);
1073        let start_points = [Pos2::new(100.0, 200.0), Pos2::new(200.0, 100.0)];
1074        let start_chart = [ChartPoint::new(10.0, 800.0), ChartPoint::new(20.0, 900.0)];
1075        let label = (
1076            vec![Pos2::new(100.0, 200.0), Pos2::new(250.0, 140.0)],
1077            vec![ChartPoint::new(10.0, 800.0), ChartPoint::new(25.0, 860.0)],
1078        );
1079        let whole = (
1080            vec![Pos2::new(150.0, 240.0), Pos2::new(250.0, 140.0)],
1081            vec![ChartPoint::new(15.0, 760.0), ChartPoint::new(25.0, 860.0)],
1082        );
1083        // A note saved before its label had an anchor has only the one it annotates.
1084        for (tool, anchors, group, copy, expected) in [
1085            (DrawingToolType::Callout, 2, false, false, &label),
1086            (DrawingToolType::Note, 2, false, false, &label),
1087            (DrawingToolType::PriceNote, 2, false, false, &label),
1088            (DrawingToolType::Note, 1, false, false, &whole),
1089            (DrawingToolType::TrendLine, 2, false, false, &whole),
1090            (DrawingToolType::Callout, 2, true, false, &whole),
1091            (DrawingToolType::Callout, 2, false, true, &whole),
1092        ] {
1093            let mut manager = DrawingManager::new();
1094            for id in [7, 8] {
1095                let mut drawing = Drawing::new(id, tool);
1096                drawing.points = start_points[..anchors].to_vec();
1097                drawing.chart_points = start_chart[..anchors].to_vec();
1098                manager.drawings.push(drawing);
1099            }
1100            if group {
1101                manager.select_drawings([7, 8]);
1102            }
1103            manager.start_drag_body(7, Pos2::new(205.0, 105.0));
1104            if copy {
1105                manager.copy_dragged_drawings();
1106            }
1107            manager.update_drag_handle(Pos2::new(255.0, 145.0), x_to_bar, y_to_price, translate);
1108            manager.end_drag_handle();
1109            let moved = manager
1110                .drawings
1111                .last()
1112                .filter(|_| copy)
1113                .unwrap_or(&manager.drawings[0]);
1114            assert_eq!(
1115                moved.points,
1116                expected.0[..anchors],
1117                "{tool:?} {group} {copy}"
1118            );
1119            assert_eq!(
1120                moved.chart_points,
1121                expected.1[..anchors],
1122                "{tool:?} {group} {copy}"
1123            );
1124            if !copy {
1125                assert!(manager.undo());
1126                assert_eq!(
1127                    manager.drawings[0].chart_points,
1128                    start_chart[..anchors],
1129                    "{tool:?}"
1130                );
1131            }
1132        }
1133    }
1134
1135    /// The second anchor of Time cycles sets only the period, and a Signpost's only the label's
1136    /// height, in the preview and when placed.
1137    #[test]
1138    fn second_anchors_follow_the_first_during_placement() {
1139        let same_price: fn(Pos2) -> Pos2 = |p| Pos2::new(p.x, 200.0);
1140        let same_bar: fn(Pos2) -> Pos2 = |p| Pos2::new(100.0, p.y);
1141        let chart = |p: Pos2| ChartPoint::new(p.x / 10.0, 1000.0 - f64::from(p.y));
1142        for (tool, aligned) in [
1143            (DrawingToolType::TimeCycles, same_price),
1144            (DrawingToolType::Signpost, same_bar),
1145        ] {
1146            for (preview, drag) in [(false, false), (true, false), (true, true)] {
1147                let mut manager = DrawingManager::new();
1148                let x_to_bar = |x: f32| x / 10.0;
1149                let y_to_price = |y: f32| 1000.0 - f64::from(y);
1150                let start = Pos2::new(100.0, 200.0);
1151                manager.start_drawing_with_coords(tool, start, x_to_bar, y_to_price);
1152                if preview {
1153                    for point in [Pos2::new(150.0, 100.0), Pos2::new(300.0, 250.0)] {
1154                        manager.update_last_point_with_coords(point, x_to_bar, y_to_price);
1155                        let drawing = manager.curr_drawing.as_ref().unwrap();
1156                        assert_eq!(drawing.points[1], aligned(point), "{tool:?}");
1157                        assert_eq!(drawing.chart_points[1], chart(aligned(point)), "{tool:?}");
1158                    }
1159                }
1160                let end = Pos2::new(300.0, 250.0);
1161                if drag {
1162                    manager.complete_drag_drawing();
1163                } else {
1164                    manager.add_point_with_coords(end, x_to_bar, y_to_price);
1165                }
1166                assert_eq!(manager.drawings.len(), 1);
1167                assert!(manager.is_selected(manager.drawings[0].id));
1168                assert_eq!(
1169                    manager.drawings[0].points,
1170                    vec![start, aligned(end)],
1171                    "{tool:?}"
1172                );
1173                assert_eq!(
1174                    manager.drawings[0].chart_points,
1175                    vec![chart(start), chart(aligned(end))],
1176                    "{tool:?}"
1177                );
1178            }
1179        }
1180    }
1181
1182    /// A rectangle and a trend line start in their own colours, the rectangle filled with a tint
1183    /// of its border; a host colour recolours both and the tint; a default saved for a tool wins.
1184    #[test]
1185    fn new_drawings_take_their_tool_style_then_the_saved_default() {
1186        let place = |manager: &mut DrawingManager, tool| {
1187            manager.start_drawing_with_coords(tool, Pos2::new(10.0, 10.0), |x| x, f64::from);
1188            manager.curr_drawing.take().unwrap()
1189        };
1190        let mut manager = DrawingManager::new();
1191        let line = place(&mut manager, DrawingToolType::TrendLine);
1192        let rect = place(&mut manager, DrawingToolType::Rect);
1193        assert_ne!(line.color, rect.color);
1194        assert_eq!(line.fill_color, None);
1195        assert_eq!(rect.fill_color.unwrap()[..3], rect.color[..3]);
1196
1197        manager.options.default_color = Some([1, 2, 3, 255]);
1198        let rect = place(&mut manager, DrawingToolType::Rect);
1199        assert_eq!(
1200            (rect.color, rect.fill_color),
1201            ([1, 2, 3, 255], Some([1, 2, 3, 51]))
1202        );
1203
1204        let mut saved = Drawing::new(0, DrawingToolType::Rect);
1205        (saved.color, saved.fill_color) = ([9, 9, 9, 255], None);
1206        let saved = crate::drawings::DrawingTemplate::from_drawing(&saved);
1207        manager.tool_defaults.insert(DrawingToolType::Rect, saved);
1208        let rect = place(&mut manager, DrawingToolType::Rect);
1209        assert_eq!((rect.color, rect.fill_color), ([9, 9, 9, 255], None));
1210
1211        // Closure belongs to the outline, so a saved default cannot close a new polyline.
1212        let mut closed = Drawing::new(0, DrawingToolType::Polyline);
1213        closed.closed = true;
1214        manager.tool_defaults.insert(
1215            DrawingToolType::Polyline,
1216            crate::drawings::DrawingTemplate::from_drawing(&closed),
1217        );
1218        assert!(!place(&mut manager, DrawingToolType::Polyline).closed);
1219        let open = Drawing::new(0, DrawingToolType::Polyline);
1220        crate::drawings::DrawingTemplate::from_drawing(&open)
1221            .apply_to(&mut closed)
1222            .unwrap();
1223        assert!(closed.closed);
1224    }
1225}