Skip to main content

egui_charts/drawings/manager/
mod.rs

1//! Drawing state, selection, editing and undo history.
2//!
3//! Use [`DrawingManager`] through the chart's drawing accessors, or directly when
4//! composing your own renderer. Direct users synchronize screen coordinates with
5//! [`DrawingManager::update_all_screen_coords`] after changing the viewport.
6
7use crate::drawings::domain::{ChartPoint, Drawing, DrawingToolType, HandlePos};
8use crate::drawings::services::{
9    HandleConfig, HistoryService, SelectionService, SnapOptions, SnapService,
10};
11use crate::tokens::DESIGN_TOKENS;
12use egui::epaint::ClippedShape;
13use egui::{Pos2, Rect};
14use std::sync::{Arc, Mutex};
15
16mod edit;
17mod interaction;
18mod paint;
19
20pub(crate) use paint::DrawingAxisTag;
21use paint::Tessellated;
22
23/// A change to the stored drawings, queued in [`DrawingManager::pending_sync`].
24///
25/// When enabled, every [`DrawingManager`] method that changes a stored drawing field queues
26/// an action per drawing it changes: creating, moving, styling, locking, hiding, layering,
27/// deleting, undo and redo. Restoring a stored drawing with
28/// [`DrawingManager::add_synced_drawing`] queues nothing. Repeated saves and updates coalesce
29/// until that drawing is deleted. The host drains the queue and saves or deletes each drawing it names,
30/// skipping saves of drawings already deleted, for example with
31/// [`drawing_to_stored`](crate::drawings::persistence::drawing_to_stored). A host that writes
32/// to [`DrawingManager::drawings`] directly queues nothing. Automatic chart storage disables
33/// these notifications; use [`DrawingManager::set_sync_enabled`] to consume both paths.
34#[derive(Debug, Clone)]
35#[non_exhaustive]
36pub enum DrawingSyncAction {
37    /// A new drawing was created and should be saved. Carries the drawing ID.
38    SaveDrawing(usize),
39    /// An existing drawing was modified (moved, resized, styled). Carries the drawing ID.
40    UpdateDrawing(usize),
41    /// A drawing was deleted. Carries the stored drawing ID as a string.
42    Delete(String),
43    /// A batch of drawings should be restored from backend storage. The manager never
44    /// queues it.
45    RestoreDrawings(Vec<crate::drawings::persistence::StoredDrawing>),
46}
47
48/// Snapshot of drawing-related state from the central application state.
49///
50/// This struct is passed to [`DrawingManager::sync_from_app_state`] at the start
51/// of each frame to ensure the manager stays in sync with toolbar selections,
52/// keyboard shortcuts, and other UI actions that modify drawing state.
53#[derive(Debug, Clone, Default)]
54pub struct DrawingState {
55    /// Currently selected drawing tool. `None` means selection/pointer mode.
56    pub active_tool: Option<DrawingToolType>,
57    /// ID of the currently selected (highlighted) drawing on the chart.
58    pub sel_drawing: Option<usize>,
59    /// Whether magnet mode is enabled (snap to OHLC prices and existing drawing points).
60    pub magnet_mode: bool,
61    /// Whether to stay in drawing mode after completing a drawing (lock mode).
62    pub stay_in_drawing_mode: bool,
63    /// Line colour every new drawing takes instead of its tool's own, as RGBA bytes; `None`
64    /// keeps each tool's default.
65    pub curr_color: Option<[u8; 4]>,
66    /// Whether eraser mode is active (click to delete drawings).
67    pub eraser_mode: bool,
68}
69
70/// Configuration options for the [`DrawingManager`].
71///
72/// Controls default colors, snapping behavior, magnet mode thresholds, and
73/// selection handle appearance.
74#[derive(Clone, Debug)]
75pub struct DrawingManagerOptions {
76    /// Line colour every new drawing takes instead of its tool's own, as RGBA bytes. `None`
77    /// (the default) keeps each tool's colour; a default saved for a tool wins over both.
78    pub default_color: Option<[u8; 4]>,
79    /// Enable snap-to-price (Y-axis snapping to OHLC levels).
80    pub snap_to_price: bool,
81    /// Enable snap-to-time (X-axis snapping to candle timestamps).
82    pub snap_to_time: bool,
83    /// Snap distance threshold in screen pixels.
84    pub snap_distance: f32,
85    /// Enable magnet mode (snap to existing drawing anchor points).
86    pub magnet_mode: bool,
87    /// Magnet snap distance threshold in screen pixels.
88    pub magnet_distance: f32,
89    /// Snap to the nearest OHLC value of the bar under the pointer regardless of distance.
90    /// Requires `magnet_mode`; `false` retains the pixel threshold and nearby overlay targets.
91    pub strong_magnet: bool,
92    /// Include visible study plots in magnet targets. Independent of OHLC and drawing anchors.
93    pub snap_to_indicators: bool,
94    /// Configuration for selection handle appearance and hit testing.
95    pub handle_config: HandleConfig,
96}
97
98impl Default for DrawingManagerOptions {
99    fn default() -> Self {
100        Self {
101            default_color: None,
102            snap_to_price: true,
103            snap_to_time: true,
104            snap_distance: DESIGN_TOKENS.spacing.lg + DESIGN_TOKENS.spacing.xs,
105            magnet_mode: false,
106            magnet_distance: DESIGN_TOKENS.sizing.drawing.magnet_distance,
107            strong_magnet: false,
108            snap_to_indicators: true,
109            handle_config: HandleConfig::default(),
110        }
111    }
112}
113
114/// Last painted geometry, invalid once its anchors have moved.
115struct PaintedDrawing {
116    id: usize,
117    points: Vec<Pos2>,
118    shapes: Vec<ClippedShape>,
119    /// A regression trend's fitted endpoints, where its handles sit.
120    regression_ends: Option<[Pos2; 2]>,
121    /// The tessellation of `shapes`, made once a second frame paints the same shapes.
122    tessellated: Option<Tessellated>,
123}
124
125/// Draft text stays separate from the drawing until the editor commits it.
126pub(crate) struct TextEditState {
127    pub id: usize,
128    pub draft: String,
129    pub focus: bool,
130    pub opened_frame: Option<u64>,
131    /// Placed just before editing, so empty content can be removed without an undo step.
132    pub placed: bool,
133    /// Paint the draft on the drawing and put the editor over its text.
134    pub inline: bool,
135}
136
137impl TextEditState {
138    /// The draft, or a localised placeholder while it is empty.
139    pub(crate) fn shown_text(&self, ctx: &egui::Context) -> String {
140        if self.draft.is_empty() {
141            crate::localization::text(ctx, "Add text")
142        } else {
143            self.draft.clone()
144        }
145    }
146
147    /// The drawing with its draft, or a neutral placeholder while it is empty.
148    pub(crate) fn preview(&self, drawing: &Drawing, ctx: &egui::Context) -> Drawing {
149        let mut shown = drawing.clone();
150        if self.draft.is_empty() {
151            shown.text_color = Some(
152                ctx.global_style()
153                    .visuals
154                    .weak_text_color()
155                    .to_srgba_unmultiplied(),
156            );
157        }
158        shown.text = Some(self.shown_text(ctx));
159        shown
160    }
161}
162
163/// Central coordinator for chart drawings.
164///
165/// `DrawingManager` owns the list of completed drawings and the in-progress
166/// drawing, and delegates business logic to specialized services:
167///
168/// - **Selection** via [`SelectionService`]
169/// - **Undo/redo** via [`HistoryService`]
170/// - **Snapping** via [`SnapService`]
171/// - **Handle manipulation** via [`HandleService`](crate::drawings::services::HandleService)
172///
173/// # Frame loop integration
174///
175/// ```
176/// # use egui_charts::drawings::{DrawingManager, DrawingState};
177/// # use egui_charts::drawings::rendering::ChartView;
178/// # fn frame(
179/// #     manager: &mut DrawingManager,
180/// #     drawing_state: &DrawingState,
181/// #     painter: &egui::Painter,
182/// #     price_rect: egui::Rect,
183/// #     bars: &[egui_charts::model::Bar],
184/// #     revision: u64,
185/// #     bar_to_x: impl Fn(f32) -> f32 + Copy,
186/// #     price_to_y: impl Fn(f64) -> f32 + Copy,
187/// #     y_to_price: impl Fn(f32) -> f64 + Copy,
188/// # ) {
189/// // 1. Sync state from app
190/// manager.sync_from_app_state(drawing_state);
191///
192/// // 2. Update screen coordinates from chart coordinates
193/// manager.update_all_screen_coords(bar_to_x, price_to_y);
194///
195/// // 3. Handle user input (clicks, drags, keyboard)
196/// // ... (start_drawing_with_coords, add_point_with_coords, etc.)
197///
198/// // 4. Render against the chart's bars and coordinate mapping
199/// let view = ChartView { price_formatter: None, time_formatter: None, bar_seconds: 0, instrument: None, bars, revision, bar_to_x: &bar_to_x, price_to_y: &price_to_y, y_to_price: &y_to_price, time_axis: true, executions: None, timeframe: None, finer: &[] };
200/// manager.render_all(painter, price_rect, &view);
201/// # }
202/// ```
203pub struct DrawingManager {
204    /// Host tools and built-in availability. Register tools before restoring their drawings.
205    pub registry: super::DrawingToolRegistry,
206    active_custom_tool: Option<String>,
207    /// Per-tool defaults, exportable with serde for host-owned application storage.
208    /// New drawings use these properties without copying geometry, identity or layer.
209    pub tool_defaults: std::collections::HashMap<DrawingToolType, super::DrawingTemplate>,
210    /// All completed drawings on the chart.
211    pub drawings: Vec<Drawing>,
212    /// Currently active drawing tool, or `None` for selection/pointer mode.
213    pub(crate) active_tool: Option<DrawingToolType>,
214    /// The drawing currently being created (not yet completed).
215    pub(crate) curr_drawing: Option<Drawing>,
216    /// Whether the last point of `curr_drawing` follows the pointer rather than a click.
217    preview_point: bool,
218    /// The last finished measurement, shown until the next click on the chart. It is never
219    /// added to `drawings`, saved or recorded for undo.
220    pub(crate) measurement: Option<Drawing>,
221    /// Next available drawing ID (monotonically increasing).
222    next_id: usize,
223
224    /// Pane used to project and drag screen-anchored annotations.
225    pane_rect: Rect,
226    pane: Option<usize>,
227    pane_members: Vec<Option<usize>>,
228    pane_clips: std::collections::HashMap<Option<usize>, Rect>,
229    pub(crate) text_editor: Option<TextEditState>,
230    /// A completed text placement whose add command may already be in chart history.
231    pub(crate) finished_text_placement: Option<usize>,
232    /// A newly placed image waiting for a host file request at the end of this frame.
233    pub(crate) image_request: Option<usize>,
234
235    // Services
236    selection: SelectionService,
237    history: HistoryService,
238    snap_service: SnapService,
239    /// Geometry from the last paint, including laid-out text and data-driven shapes.
240    painted: Mutex<Vec<PaintedDrawing>>,
241    /// Nearby bar and overlay points, refreshed by the chart before pointer handling.
242    pub(crate) snap_targets: Vec<Pos2>,
243
244    /// Manager configuration (colors, snapping, handles).
245    pub options: DrawingManagerOptions,
246
247    /// Currently dragged handle, if any: `(drawing_id, handle_position)`.
248    pub(crate) dragging_handle: Option<(usize, HandlePos)>,
249    /// Snapshot of the drawing state before the current drag began (for undo).
250    drag_old_state: Option<Drawing>,
251    /// Pointer origin of a body or centre-handle translation.
252    drag_origin: Option<Pos2>,
253    /// Other selected drawings before a body translation.
254    drag_group: Vec<Drawing>,
255    /// The current drag creates copies instead of modifying the original selection.
256    drag_copy: bool,
257    /// Current chart input, separate from the toolbar magnet setting.
258    pub(crate) interaction_modifiers: egui::Modifiers,
259    /// A line tool chosen by its shortcut this frame, placed by the pane under the pointer.
260    pub(crate) place_at_pointer: bool,
261
262    /// Whether to stay in drawing mode after completing a drawing.
263    /// A finished measurement always returns to selection mode.
264    pub stay_in_drawing_mode: bool,
265    /// Current chart timeframe string (e.g., `"1D"`, `"1h"`): drawings hidden on it are
266    /// neither drawn nor hit. [`Chart::set_timeframe`](crate::widget::Chart::set_timeframe)
267    /// keeps it in step with the chart.
268    pub curr_timeframe: String,
269    /// Whether a click deletes the drawing under the pointer instead of selecting it. Set it
270    /// with [`Self::set_eraser_mode`].
271    pub(crate) eraser_mode: bool,
272
273    /// Changes to the stored drawings, oldest first, for the host to drain, for example
274    /// with `std::mem::take(&mut *manager.pending_sync.lock().unwrap())`. See
275    /// [`DrawingSyncAction`].
276    pub pending_sync: Arc<Mutex<Vec<DrawingSyncAction>>>,
277    /// Whether edits emit per-drawing storage notifications.
278    pub(crate) sync_enabled: bool,
279}
280
281impl Default for DrawingManager {
282    fn default() -> Self {
283        Self::new()
284    }
285}
286
287impl DrawingManager {
288    /// Enables or disables per-drawing storage notifications. Disabling clears pending actions.
289    /// Automatic chart storage disables them; enable them again to also consume notifications.
290    /// Changes made while disabled are not reported.
291    pub fn set_sync_enabled(&mut self, enabled: bool) {
292        self.sync_enabled = enabled;
293        if !enabled {
294            self.pending_sync
295                .lock()
296                .unwrap_or_else(|error| error.into_inner())
297                .clear();
298        }
299    }
300
301    /// Creates a new `DrawingManager` with default options and empty drawing list.
302    pub fn new() -> Self {
303        Self {
304            registry: Default::default(),
305            active_custom_tool: None,
306            drawings: Vec::new(),
307            tool_defaults: Default::default(),
308            active_tool: None,
309            curr_drawing: None,
310            preview_point: false,
311            measurement: None,
312            next_id: 0,
313            pane_rect: Rect::NOTHING,
314            pane: None,
315            pane_members: vec![None],
316            pane_clips: std::collections::HashMap::new(),
317            text_editor: None,
318            finished_text_placement: None,
319            image_request: None,
320            selection: SelectionService::new(),
321            history: HistoryService::new(),
322            snap_service: SnapService::new(),
323            painted: Mutex::new(Vec::new()),
324            snap_targets: Vec::new(),
325            options: DrawingManagerOptions::default(),
326            dragging_handle: None,
327            drag_old_state: None,
328            drag_origin: None,
329            drag_group: Vec::new(),
330            drag_copy: false,
331            interaction_modifiers: egui::Modifiers::NONE,
332            place_at_pointer: false,
333            stay_in_drawing_mode: false,
334            curr_timeframe: String::from("1D"),
335            eraser_mode: false,
336            pending_sync: Arc::new(Mutex::new(Vec::new())),
337            sync_enabled: true,
338        }
339    }
340
341    /// Apply the host's tool, snapping, color, eraser and selection state. A state with both
342    /// a tool and the eraser leaves only the eraser on, as [`Self::set_eraser_mode`] does.
343    pub fn sync_from_app_state(&mut self, drawing_state: &DrawingState) {
344        if self.active_tool != drawing_state.active_tool {
345            self.set_active_tool(drawing_state.active_tool);
346        }
347        self.options.magnet_mode = drawing_state.magnet_mode;
348        self.options.default_color = drawing_state.curr_color;
349        self.stay_in_drawing_mode = drawing_state.stay_in_drawing_mode;
350        if self.eraser_mode != drawing_state.eraser_mode {
351            self.set_eraser_mode(drawing_state.eraser_mode);
352        }
353
354        // Sync selection
355        match (drawing_state.sel_drawing, self.selection.primary()) {
356            (Some(id), current) if current != Some(id) => {
357                self.select(id);
358            }
359            (None, Some(_)) => {
360                self.selection.deselect_all();
361            }
362            _ => {} // Already in sync
363        }
364    }
365
366    // === Selection delegation ===
367
368    /// Returns the ID of the currently selected (primary) drawing, if any.
369    pub fn sel_drawing(&self) -> Option<usize> {
370        self.selection
371            .primary()
372            .filter(|id| self.is_selected(*id))
373            .or_else(|| self.selected_ids().next())
374    }
375
376    /// Selects the drawing with the given ID (deselects all others).
377    pub fn select(&mut self, id: usize) {
378        self.select_drawings([id]);
379    }
380
381    /// Replace the selected drawings with the supplied live ids.
382    pub fn select_drawings(&mut self, ids: impl IntoIterator<Item = usize>) {
383        self.selection.deselect_all();
384        self.selection.select_all(
385            ids.into_iter()
386                .filter(|id| self.drawings.iter().any(|d| d.id == *id && d.selectable)),
387        );
388    }
389
390    /// Selected live drawing ids, in drawing-list order.
391    pub fn selected_ids(&self) -> impl Iterator<Item = usize> + '_ {
392        self.drawings
393            .iter()
394            .filter(|d| d.selectable && self.selection.is_selected(d.id))
395            .map(|d| d.id)
396    }
397
398    /// Whether a drawing belongs to the current selection.
399    pub fn is_selected(&self, id: usize) -> bool {
400        self.drawings.iter().any(|d| d.id == id && d.selectable) && self.selection.is_selected(id)
401    }
402
403    /// Toggle a live drawing in the selection, retaining the other selected drawings.
404    pub fn toggle_selection(&mut self, id: usize) {
405        if self.drawings.iter().any(|d| d.id == id && d.selectable) {
406            self.selection.toggle(id);
407        }
408    }
409
410    /// Apply a style edit to every unlocked selected drawing as one undo step.
411    pub fn edit_selected(&mut self, mut edit: impl FnMut(&mut Drawing)) {
412        let before: Vec<_> = self
413            .drawings
414            .iter()
415            .enumerate()
416            .filter(|(_, d)| d.selectable && self.selection.is_selected(d.id) && !d.locked)
417            .map(|(index, d)| (index, d.clone()))
418            .collect();
419        if before.is_empty() {
420            return;
421        }
422        for (index, old) in &before {
423            edit(&mut self.drawings[*index]);
424            self.queue_edit(old);
425        }
426        self.painted
427            .get_mut()
428            .unwrap_or_else(|e| e.into_inner())
429            .retain(|p| !before.iter().any(|(_, d)| d.id == p.id));
430        self.history.push_snapshot(before);
431    }
432
433    /// Deselects all drawings.
434    pub fn deselect(&mut self) {
435        self.selection.deselect_all();
436    }
437
438    /// Hit-tests at `point` and selects the topmost drawing found.
439    ///
440    /// Returns `true` if a drawing was selected, `false` if the click hit empty space
441    /// (which deselects all drawings).
442    pub fn select_at(&mut self, point: Pos2) -> bool {
443        if let Some(id) = self.hit_test(point) {
444            self.select(id);
445            true
446        } else {
447            self.selection.deselect_all();
448            false
449        }
450    }
451
452    // === History delegation ===
453
454    /// Exchange the standalone history with a chart user action's drawing commands.
455    pub(crate) fn swap_history(&mut self, history: &mut HistoryService) {
456        std::mem::swap(&mut self.history, history);
457    }
458
459    /// Undoes the last drawing operation. Returns `true` if an operation was undone.
460    pub fn undo(&mut self) -> bool {
461        self.step_history(HistoryService::undo)
462    }
463
464    /// Redoes the last undone operation. Returns `true` if an operation was redone.
465    pub fn redo(&mut self) -> bool {
466        self.step_history(HistoryService::redo)
467    }
468
469    /// Runs an undo or redo step and queues the change it made to its drawing.
470    fn step_history(
471        &mut self,
472        step: fn(&mut HistoryService, &mut Vec<Drawing>) -> Option<usize>,
473    ) -> bool {
474        let before: Vec<_> = self
475            .drawings
476            .iter()
477            .filter(|d| d.saveable)
478            .map(|d| (d.id, sync_id(d)))
479            .collect();
480        let Some(_) = step(&mut self.history, &mut self.drawings) else {
481            return false;
482        };
483        self.painted
484            .get_mut()
485            .unwrap_or_else(|e| e.into_inner())
486            .retain(|p| !self.history.affected_ids().contains(&p.id));
487        for &id in self.history.affected_ids() {
488            let was = before.iter().find(|&&(i, _)| i == id);
489            match (was, self.drawings.iter().any(|d| d.id == id && d.saveable)) {
490                (Some(_), true) => self.queue(DrawingSyncAction::UpdateDrawing(id)),
491                (Some((_, stored_id)), false) => {
492                    self.queue(DrawingSyncAction::Delete(stored_id.clone()))
493                }
494                (None, true) => self.queue(DrawingSyncAction::SaveDrawing(id)),
495                (None, false) => {}
496            }
497        }
498        self.selection
499            .prune(&self.drawings.iter().map(|d| d.id).collect());
500        true
501    }
502
503    /// Queues a change for the host to save.
504    fn queue(&self, action: DrawingSyncAction) {
505        if !self.sync_enabled {
506            return;
507        }
508        let drawing = match &action {
509            DrawingSyncAction::SaveDrawing(id) | DrawingSyncAction::UpdateDrawing(id) => {
510                self.drawings.iter().find(|drawing| drawing.id == *id)
511            }
512            _ => None,
513        };
514        if drawing.is_some_and(|drawing| !drawing.saveable) {
515            return;
516        }
517        if let Ok(mut sync) = self.pending_sync.lock() {
518            queue_sync(&mut sync, action, drawing.map(sync_id).as_deref());
519        }
520    }
521
522    /// Returns `true` if there are operations available to undo.
523    pub fn can_undo(&self) -> bool {
524        self.history.can_undo()
525    }
526
527    /// Returns `true` if there are operations available to redo.
528    pub fn can_redo(&self) -> bool {
529        self.history.can_redo()
530    }
531
532    // === Snap delegation ===
533
534    /// Rebuilds the internal [`SnapService`] from the current manager options.
535    /// Call this after changing snap-related options.
536    pub fn update_snap_options(&mut self) {
537        self.snap_service = SnapService::with_options(SnapOptions {
538            snap_to_price: self.options.snap_to_price,
539            snap_to_time: self.options.snap_to_time,
540            snap_distance: self.options.snap_distance,
541            magnet_mode: self.options.magnet_mode,
542            magnet_distance: self.options.magnet_distance,
543        });
544    }
545
546    pub(crate) fn magnet_enabled(&self) -> bool {
547        self.options.magnet_mode
548            ^ (self.interaction_modifiers.ctrl || self.interaction_modifiers.command)
549    }
550
551    /// Applies snap behavior to a screen point, returning the snapped position.
552    ///
553    /// With magnet mode on, snaps to nearby chart values and visible anchors of `drawings`.
554    /// Targets keep their time and price paired. A dragged drawing cannot snap to itself.
555    /// Drawing anchors are borrowed without flattening or cloning their geometry.
556    pub fn snap_point(&self, point: Pos2, drawings: &[Drawing]) -> Pos2 {
557        if self.magnet_enabled()
558            && self.options.strong_magnet
559            && let Some(target) = self
560                .snap_targets
561                .iter()
562                .filter(|p| p.is_finite())
563                .min_by(|a, b| (a.y - point.y).abs().total_cmp(&(b.y - point.y).abs()))
564        {
565            return *target;
566        }
567        let mut service = self.snap_service.clone();
568        service.set_magnet_mode(self.magnet_enabled());
569        service.snap_point_with_drawing_points(
570            point,
571            &[],
572            &[],
573            self.snap_targets.iter().copied().chain(
574                drawings
575                    .iter()
576                    .filter(|d| {
577                        d.is_visible_on_timeframe(&self.curr_timeframe)
578                            && self.pane_members.contains(&d.pane)
579                            && self.dragging_handle.is_none_or(|(id, _)| id != d.id)
580                            && self.drag_group.iter().all(|old| old.id != d.id)
581                    })
582                    .flat_map(|d| d.points.iter().copied()),
583            ),
584        )
585    }
586
587    fn queue_edit(&self, old: &Drawing) {
588        if let Some(drawing) = self.drawings.iter().find(|d| d.id == old.id) {
589            self.queue(match (old.saveable, drawing.saveable) {
590                (true, false) => DrawingSyncAction::Delete(sync_id(old)),
591                (false, true) => DrawingSyncAction::SaveDrawing(old.id),
592                _ => DrawingSyncAction::UpdateDrawing(old.id),
593            });
594        }
595    }
596
597    /// Queues an update of the drawing with the given ID, if there is one.
598    fn queue_update(&self, id: usize) {
599        if self.drawings.iter().any(|d| d.id == id) {
600            self.queue(DrawingSyncAction::UpdateDrawing(id));
601        }
602    }
603
604    /// An id above every drawing's id and every numeric id a drawing is stored under, so a new
605    /// drawing is never saved over a stored one. A stored id too large to leave room above it
606    /// is not one this manager hands out, so it cannot collide and is left out.
607    fn take_id(&mut self) -> usize {
608        let floor = self
609            .drawings
610            .iter()
611            .flat_map(|d| {
612                [
613                    Some(d.id),
614                    d.stored_id
615                        .as_deref()
616                        .and_then(|s| s.parse().ok())
617                        .filter(|&id: &usize| id < usize::MAX / 2),
618                ]
619            })
620            .flatten()
621            .max()
622            .map_or(0, |id: usize| id.saturating_add(1));
623        let id = self.next_id.max(floor);
624        self.next_id = id.saturating_add(1);
625        id
626    }
627
628    /// What [`Self::keep_across_read`] needs of the drawings about to be replaced.
629    pub(crate) fn before_read(&self) -> BeforeRead {
630        BeforeRead {
631            // A drawing that is not saveable is in no document, so its id is no stored id.
632            ids: self
633                .drawings
634                .iter()
635                .filter(|d| d.saveable)
636                .map(|d| (sync_id(d), d.id))
637                .collect(),
638            next_id: self.next_id,
639            unsaved: self
640                .drawings
641                .iter()
642                .filter(|d| !d.saveable)
643                .cloned()
644                .collect(),
645        }
646    }
647
648    /// Gives drawings just read from storage the local ids they had before, so that what refers
649    /// to a drawing by its local id, such as an open properties dialog, keeps referring to it.
650    /// Every other read drawing gets an id that no drawing of the manager has or had, so that it
651    /// is not taken for one removed elsewhere. Drawings that are never stored stay, since no
652    /// document holds them.
653    pub(crate) fn keep_across_read(&mut self, previous: BeforeRead) {
654        let BeforeRead {
655            ids,
656            next_id,
657            unsaved,
658        } = previous;
659        self.next_id = self.next_id.max(next_id);
660        // The drawings that stay are in place before ids are handed out, so none gets theirs.
661        let read = self.drawings.len();
662        self.drawings.extend(unsaved);
663        let mut fresh = Vec::new();
664        for (at, drawing) in self.drawings[..read].iter_mut().enumerate() {
665            match drawing.stored_id.as_ref().and_then(|id| ids.get(id)) {
666                Some(id) => drawing.id = *id,
667                None => fresh.push(at),
668            }
669        }
670        for at in fresh {
671            self.drawings[at].id = self.take_id();
672        }
673    }
674
675    /// A new drawing of `tool` in its default style: the tool's own, recoloured by
676    /// [`DrawingManagerOptions::default_color`] when set, then the default saved for the tool.
677    pub fn new_drawing(&self, id: usize, tool: DrawingToolType) -> Drawing {
678        let mut drawing = match self.options.default_color {
679            Some(color) => Drawing::with_color(id, tool, color),
680            None => Drawing::new(id, tool),
681        };
682        if let Some(defaults) = self.tool_defaults.get(&tool) {
683            let _ = defaults.apply_to(&mut drawing);
684        }
685        drawing
686    }
687
688    /// The z-order that puts a drawing added now on top of the others.
689    pub(crate) fn top_z_order(&self) -> i32 {
690        self.drawings.iter().map(|d| d.z_order).max().unwrap_or(0)
691    }
692
693    pub(crate) fn visit_chart_points(&mut self, visit: &mut impl FnMut(&mut ChartPoint)) {
694        for drawing in self
695            .drawings
696            .iter_mut()
697            .chain(&mut self.curr_drawing)
698            .chain(&mut self.measurement)
699            .chain(&mut self.drag_old_state)
700            .chain(&mut self.drag_group)
701        {
702            drawing.chart_points.iter_mut().for_each(&mut *visit);
703        }
704        self.history.visit_chart_points(visit);
705    }
706}
707
708/// What a manager keeps when drawings read from storage replace its own.
709pub(crate) struct BeforeRead {
710    /// The local id of each drawing by the id it is stored under.
711    ids: std::collections::HashMap<String, usize>,
712    next_id: usize,
713    /// The drawings that are never stored.
714    unsaved: Vec<Drawing>,
715}
716
717/// Coalesces repeated saves and updates of a drawing without reordering them across its delete.
718fn queue_sync(
719    sync: &mut Vec<DrawingSyncAction>,
720    action: DrawingSyncAction,
721    stored_id: Option<&str>,
722) {
723    if let DrawingSyncAction::SaveDrawing(id) | DrawingSyncAction::UpdateDrawing(id) = &action {
724        for pending in sync.iter_mut().rev() {
725            match pending {
726                DrawingSyncAction::Delete(deleted) if Some(deleted.as_str()) == stored_id => break,
727                DrawingSyncAction::SaveDrawing(other) | DrawingSyncAction::UpdateDrawing(other)
728                    if other == id =>
729                {
730                    if matches!(action, DrawingSyncAction::SaveDrawing(_)) {
731                        *pending = action;
732                    }
733                    return;
734                }
735                _ => {}
736            }
737        }
738    }
739    sync.push(action);
740}
741
742/// The id a drawing is stored under.
743pub(crate) fn sync_id(drawing: &Drawing) -> String {
744    drawing
745        .stored_id
746        .clone()
747        .unwrap_or_else(|| drawing.id.to_string())
748}
749
750#[cfg(test)]
751mod tests {
752    use super::*;
753
754    /// Two clicks and Enter finish a multi-point tool with two anchors. Whatever the
755    /// manager finishes must save, and a tool that draws nothing with two anchors (the parallel
756    /// channel and the pitchforks) must stay in progress, or the whole storage would refuse to save.
757    #[test]
758    fn a_drawing_finished_early_can_be_saved() {
759        use crate::drawings::persistence::{DrawingStorage, drawing_to_stored};
760        for (tool, finishes) in [
761            (DrawingToolType::Triangle, true),
762            (DrawingToolType::Pitchfork, false),
763            (DrawingToolType::SchiffPitchfork, false),
764            (DrawingToolType::ModifiedSchiffPitchfork, false),
765            (DrawingToolType::InsidePitchfork, false),
766            (DrawingToolType::FlatTopBottom, true),
767            (DrawingToolType::ParallelChannel, false),
768        ] {
769            let mut manager = DrawingManager::new();
770            let (x_to_bar, y_to_price) = (|x: f32| x, |y: f32| f64::from(y));
771            manager.start_drawing_with_coords(tool, Pos2::new(10.0, 10.0), x_to_bar, y_to_price);
772            manager.add_point_with_coords(Pos2::new(50.0, 40.0), x_to_bar, y_to_price);
773            manager.complete_curr_drawing();
774            assert_eq!(manager.drawings.len(), usize::from(finishes), "{tool:?}");
775            let mut storage = DrawingStorage::new("AAPL");
776            for drawing in &manager.drawings {
777                storage.add_drawing(drawing_to_stored(drawing, |bar| 60_000 * bar as i64));
778            }
779            assert!(
780                storage.to_json().is_ok(),
781                "{tool:?}: {:?}",
782                storage.to_json()
783            );
784        }
785    }
786
787    /// The pointer moves between the clicks that place a drawing's anchors. The drawing must
788    /// finish on the click that places its last anchor and keep exactly the clicked positions,
789    /// with no anchor left at a hovered position.
790    #[test]
791    fn hovering_between_clicks_adds_no_anchor() {
792        for (tool, clicks) in [
793            (DrawingToolType::TrendLine, 2),
794            (DrawingToolType::ParallelChannel, 3),
795            (DrawingToolType::XABCDPattern, 5),
796            (DrawingToolType::Polyline, 3),
797        ] {
798            let mut manager = DrawingManager::new();
799            let (x_to_bar, y_to_price) = (|x: f32| x, |y: f32| f64::from(y));
800            let click = |i: usize| Pos2::new(10.0 * (i + 1) as f32, 5.0 * (i % 2) as f32);
801            manager.start_drawing_with_coords(tool, click(0), x_to_bar, y_to_price);
802            for i in 1..clicks {
803                manager.update_last_point_with_coords(Pos2::new(99.0, 99.0), x_to_bar, y_to_price);
804                assert!(
805                    manager.drawings.is_empty(),
806                    "{tool:?} finished after {i} clicks"
807                );
808                manager.add_point_with_coords(click(i), x_to_bar, y_to_price);
809            }
810            manager.update_last_point_with_coords(Pos2::new(99.0, 99.0), x_to_bar, y_to_price);
811            manager.complete_curr_drawing();
812            let want: Vec<_> = (0..clicks)
813                .map(|i| ChartPoint::new(click(i).x, f64::from(click(i).y)))
814                .collect();
815            assert_eq!(manager.drawings.len(), 1, "{tool:?}");
816            assert_eq!(manager.drawings[0].chart_points, want, "{tool:?}");
817        }
818    }
819
820    /// A locked drawing keeps its place when one of its handles is dragged and survives a
821    /// delete or style edit, until it is explicitly unlocked.
822    #[test]
823    fn a_locked_drawing_is_neither_moved_nor_deleted() {
824        for bulk in [false, true] {
825            let edit = |manager: &mut DrawingManager, change: fn(&mut Drawing)| {
826                if bulk {
827                    manager.edit_drawings(&[3], change);
828                } else {
829                    manager.edit_drawing(3, change);
830                }
831            };
832            let mut manager = DrawingManager::new();
833            let mut line = Drawing::new(3, DrawingToolType::TrendLine);
834            line.chart_points = vec![ChartPoint::new(10.0, 10.0), ChartPoint::new(20.0, 20.0)];
835            line.update_screen_coords(|bar| bar, |price| price as f32);
836            line.locked = true;
837            manager.drawings.push(line);
838
839            manager.start_drag_handle(3, HandlePos::Start);
840            manager.update_drag_handle(
841                Pos2::new(50.0, 50.0),
842                |x| x,
843                f64::from,
844                |price, from, to| price + f64::from(to - from),
845            );
846            manager.end_drag_handle();
847            manager.delete_drawing(3);
848            edit(&mut manager, |d| d.font_size = 30.0);
849            manager.drag_edit_drawing(3, |d| d.font_size = 40.0);
850            manager.end_drag_handle();
851            assert_eq!(manager.drawings[0].font_size, 14.0);
852            assert!(!manager.can_undo());
853            assert!(manager.pending_sync.lock().unwrap().is_empty());
854            // Tree metadata can change without unlocking geometry or style edits.
855            edit(&mut manager, |d| {
856                d.visible = false;
857                d.label = Some("Support".into());
858                d.folder = vec!["Analysis".into()];
859                d.font_size = 30.0;
860            });
861            assert!(!manager.drawings[0].visible);
862            assert_eq!(manager.drawings[0].label.as_deref(), Some("Support"));
863            assert_eq!(manager.drawings[0].folder, ["Analysis"]);
864            assert_eq!(manager.drawings[0].font_size, 14.0);
865            assert!(manager.drawings[0].locked);
866            assert!(manager.undo());
867            assert!(manager.drawings[0].visible);
868            assert_eq!(manager.drawings[0].label, None);
869            assert!(manager.drawings[0].folder.is_empty());
870            assert!(manager.drawings[0].locked);
871            edit(&mut manager, |d| d.locked = false);
872            edit(&mut manager, |d| d.font_size = 30.0);
873            assert_eq!(manager.drawings[0].font_size, 30.0);
874
875            assert_eq!(manager.drawings.len(), 1);
876            assert_eq!(
877                manager.drawings[0].chart_points[0],
878                ChartPoint::new(10.0, 10.0)
879            );
880        }
881    }
882
883    /// A bulk command on drawing 2 records only that drawing, so its undo and redo keep
884    /// changes made to the others outside history.
885    #[test]
886    fn bulk_edits_preserve_unrelated_host_changes_and_drawing_order() {
887        for edit in [
888            (|m: &mut DrawingManager| m.edit_drawings(&[2], |d| d.folder = vec!["Analysis".into()]))
889                as fn(&mut DrawingManager),
890            |m| m.edit_drawings(&[2], |d| d.visible = false),
891            |m| m.edit_drawings(&[2], |d| d.locked = true),
892            |m| {
893                m.select(2);
894                m.edit_selected(|d| d.color = [255, 0, 0, 255]);
895            },
896            |m| m.set_group(&[2], Some("Group".into())),
897        ] {
898            let mut manager = DrawingManager::new();
899            manager.drawings = (0..3)
900                .map(|id| Drawing::new(id, DrawingToolType::HorizontalLine))
901                .collect();
902            edit(&mut manager);
903            manager.pending_sync.lock().unwrap().clear();
904            // Host updates outside this edit must survive both history directions.
905            manager.drawings[0].label = Some("Host update".into());
906            manager.drawings.remove(1);
907            manager
908                .drawings
909                .push(Drawing::new(3, DrawingToolType::HorizontalLine));
910            for step in [DrawingManager::undo, DrawingManager::redo] {
911                assert!(step(&mut manager));
912                assert_eq!(
913                    manager.drawings.iter().map(|d| d.id).collect::<Vec<_>>(),
914                    [0, 2, 3]
915                );
916                assert_eq!(manager.drawings[0].label.as_deref(), Some("Host update"));
917                assert!(matches!(
918                    manager.pending_sync.lock().unwrap().as_slice(),
919                    [DrawingSyncAction::UpdateDrawing(2)]
920                ));
921            }
922        }
923    }
924
925    /// Of two overlapping drawings, the one with the higher `z_order` is hit, whichever was
926    /// added last, the manager's layer operations set `z_order`, which is what is saved, and a
927    /// drawing a host adds goes on top.
928    #[test]
929    fn z_order_decides_which_overlapping_drawing_is_hit() {
930        use crate::drawings::services::ZOrderService;
931        let mut manager = DrawingManager::new();
932        for id in [1, 2] {
933            let mut line = Drawing::new(id, DrawingToolType::HorizontalLine);
934            line.chart_points = vec![ChartPoint::new(10.0, 50.0)];
935            line.update_screen_coords(|bar| bar, |price| price as f32);
936            manager.drawings.push(line);
937        }
938        let on_both = Pos2::new(30.0, 50.0);
939        let z = |m: &DrawingManager, id| m.drawings.iter().find(|d| d.id == id).unwrap().z_order;
940        assert_eq!(manager.hit_test(on_both), Some(2));
941
942        ZOrderService::bring_to_front(&mut manager.drawings, 1);
943        assert_eq!(manager.hit_test(on_both), Some(1));
944
945        manager.send_to_back(1);
946        assert_eq!(manager.hit_test(on_both), Some(2));
947        assert!(z(&manager, 1) < z(&manager, 2));
948
949        manager.bring_to_front(1);
950        assert_eq!(manager.hit_test(on_both), Some(1));
951        assert!(z(&manager, 1) > z(&manager, 2));
952
953        // A drawing a host adds, never stored, goes on top of one at z-order 5.
954        manager
955            .drawings
956            .iter_mut()
957            .find(|d| d.id == 1)
958            .unwrap()
959            .z_order = 5;
960        let mut fresh = Drawing::new(0, DrawingToolType::HorizontalLine);
961        fresh.chart_points = vec![ChartPoint::new(10.0, 50.0)];
962        fresh.update_screen_coords(|bar| bar, |price| price as f32);
963        manager.add_synced_drawing(fresh).unwrap();
964        let fresh_id = manager.drawings.last().unwrap().id;
965        assert_eq!(manager.hit_test(on_both), Some(fresh_id));
966
967        // Pasting a lower layer puts the copy above existing drawings too.
968        manager.paste_drawings(&[manager.drawings[1].clone()]);
969        manager.update_all_screen_coords(|bar| bar, |price| price as f32);
970        let copy_id = manager.drawings.last().unwrap().id;
971        assert_eq!(manager.hit_test(on_both), Some(copy_id));
972        assert!(manager.undo());
973        assert_eq!(manager.hit_test(on_both), Some(fresh_id));
974    }
975
976    /// Stored-field edits coalesce into notifications of the current drawing state.
977    /// Restoring a stored drawing queues nothing,
978    /// and deleting it names the record it is stored under.
979    #[test]
980    fn edits_queue_coalesced_sync_actions() {
981        fn line(id: usize) -> Drawing {
982            let mut drawing = Drawing::new(id, DrawingToolType::HorizontalLine);
983            drawing.chart_points = vec![ChartPoint::new(1.0, 10.0)];
984            drawing
985        }
986        type Edit = fn(&mut DrawingManager);
987        let cases: [(Edit, &[&str]); 15] = [
988            (
989                |m| {
990                    m.duplicate_drawing(0);
991                    m.undo();
992                    m.redo();
993                },
994                &["Save(1)", "Delete(1)", "Save(1)"],
995            ),
996            (
997                |m| {
998                    m.add_synced_drawing(line(1)).unwrap();
999                    m.reorder_drawings(0, &[(0, 1), (1, 2)]);
1000                    m.undo();
1001                    m.redo();
1002                },
1003                &["Save(1)", "Update(0)"],
1004            ),
1005            (|m| m.clear_all(), &["Delete(a1b2)"]),
1006            (|m| m.toggle_visibility_all(), &["Update(0)"]),
1007            (|m| m.toggle_lock_all(), &["Update(0)"]),
1008            (|m| m.bring_to_front(0), &["Update(0)"]),
1009            (|m| m.send_to_back(0), &["Update(0)"]),
1010            (
1011                |m| {
1012                    m.delete_drawing(0);
1013                    m.undo();
1014                    m.redo();
1015                },
1016                &["Delete(a1b2)", "Save(0)", "Delete(a1b2)"],
1017            ),
1018            (
1019                |m| {
1020                    let rect = DrawingToolType::Rect;
1021                    m.start_drawing_with_coords(rect, Pos2::new(1.0, 1.0), |x| x, f64::from);
1022                    m.update_last_point_with_coords(Pos2::new(9.0, 9.0), |x| x, f64::from);
1023                    m.complete_drag_drawing();
1024                },
1025                &["Save(1)"],
1026            ),
1027            (
1028                |m| {
1029                    let line = DrawingToolType::Polyline;
1030                    m.start_drawing_with_coords(line, Pos2::new(1.0, 1.0), |x| x, f64::from);
1031                    m.add_point_with_coords(Pos2::new(9.0, 9.0), |x| x, f64::from);
1032                    m.complete_curr_drawing();
1033                },
1034                &["Save(1)"],
1035            ),
1036            (
1037                |m| {
1038                    m.add_synced_drawing(line(7)).unwrap();
1039                },
1040                &["Save(1)"],
1041            ),
1042            (
1043                |m| m.edit_drawing(0, |d| d.stroke_width = 3.0),
1044                &["Update(0)"],
1045            ),
1046            (
1047                // A colour picker dragged over 120 frames is one undo step and one update.
1048                |m| {
1049                    for red in 0..120 {
1050                        m.drag_edit_drawing(0, |d| d.color = [red, 0, 0, 255]);
1051                    }
1052                    m.end_drag_handle();
1053                    m.undo();
1054                    m.undo();
1055                },
1056                &["Update(0)"],
1057            ),
1058            (
1059                |m| {
1060                    for i in 0..10_000 {
1061                        m.edit_drawing(0, |d| d.stroke_width = 2.0 + (i % 2) as f32);
1062                    }
1063                    m.toggle_visibility_all();
1064                    m.toggle_lock_all();
1065                },
1066                &["Update(0)"],
1067            ),
1068            (
1069                |m| {
1070                    m.edit_drawing(0, |d| d.stroke_width = 2.0);
1071                    m.add_synced_drawing(line(1)).unwrap();
1072                    m.delete_drawing(1);
1073                    m.edit_drawing(0, |d| d.stroke_width = 3.0);
1074                },
1075                &["Update(0)", "Save(1)", "Delete(1)"],
1076            ),
1077        ];
1078        for (case, (edit, expected)) in cases.into_iter().enumerate() {
1079            let mut manager = DrawingManager::new();
1080            let mut restored = line(5);
1081            restored.stored_id = Some("a1b2".into());
1082            manager.add_synced_drawing(restored).unwrap();
1083            let before = manager
1084                .drawings
1085                .iter()
1086                .map(|drawing| (drawing.id, serde_json::to_value(drawing).unwrap()))
1087                .collect::<Vec<_>>();
1088            edit(&mut manager);
1089            let after = manager
1090                .drawings
1091                .iter()
1092                .map(|drawing| (drawing.id, serde_json::to_value(drawing).unwrap()))
1093                .collect::<Vec<_>>();
1094            let queued: Vec<String> = manager
1095                .pending_sync
1096                .lock()
1097                .unwrap()
1098                .iter()
1099                .map(|action| match action {
1100                    DrawingSyncAction::SaveDrawing(id) => format!("Save({id})"),
1101                    DrawingSyncAction::UpdateDrawing(id) => format!("Update({id})"),
1102                    DrawingSyncAction::Delete(id) => format!("Delete({id})"),
1103                    DrawingSyncAction::RestoreDrawings(_) => "Restore".into(),
1104                })
1105                .collect();
1106            assert_eq!(queued, expected);
1107            if matches!(case, 0 | 2..=6 | 10 | 11) {
1108                assert!(manager.undo(), "case {case} must be undoable");
1109                assert_eq!(
1110                    manager
1111                        .drawings
1112                        .iter()
1113                        .map(|drawing| (drawing.id, serde_json::to_value(drawing).unwrap()))
1114                        .collect::<Vec<_>>(),
1115                    before,
1116                    "case {case}: undo"
1117                );
1118                assert!(!manager.can_undo(), "case {case}: one command per edit");
1119                assert!(manager.redo(), "case {case}");
1120                assert_eq!(
1121                    manager
1122                        .drawings
1123                        .iter()
1124                        .map(|drawing| (drawing.id, serde_json::to_value(drawing).unwrap()))
1125                        .collect::<Vec<_>>(),
1126                    after,
1127                    "case {case}: redo"
1128                );
1129            }
1130        }
1131    }
1132
1133    /// The click after an icon is picked places that icon, not a default one, and so does every
1134    /// later click in keep-drawing mode.
1135    #[test]
1136    fn a_picked_icon_is_placed_by_the_next_click() {
1137        for glyph in [
1138            "icon:check",
1139            "icon:star",
1140            "😀",
1141            "👍",
1142            "🔥",
1143            "🍎",
1144            "🚀",
1145            "📌",
1146            "⭐",
1147            "sticker:bull",
1148        ] {
1149            for (keep_drawing, clicks) in [(false, 1), (true, 3)] {
1150                let mut manager = DrawingManager::new();
1151                manager.stay_in_drawing_mode = keep_drawing;
1152                manager.start_icon_insertion(glyph.into());
1153                manager.curr_drawing.as_mut().unwrap().font_size = 48.0;
1154                for i in 0..clicks {
1155                    let tool = manager.active_tool.unwrap();
1156                    assert_eq!(
1157                        tool,
1158                        if glyph.starts_with("sticker:") {
1159                            DrawingToolType::Sticker
1160                        } else {
1161                            DrawingToolType::FontIcon
1162                        }
1163                    );
1164                    let at = Pos2::new(5.0 * (i + 1) as f32, 5.0);
1165                    manager.start_drawing_with_coords(tool, at, |x| x, f64::from);
1166                }
1167                let placed: Vec<_> = manager
1168                    .drawings
1169                    .iter()
1170                    .map(|d| (d.text.as_deref(), d.font_size))
1171                    .collect();
1172                assert_eq!(
1173                    placed,
1174                    vec![(Some(glyph), 48.0); clicks],
1175                    "{glyph}, keep drawing {keep_drawing}"
1176                );
1177            }
1178        }
1179    }
1180
1181    #[test]
1182    fn test_selection() {
1183        let mut manager = DrawingManager::new();
1184        manager.select(1);
1185        assert_eq!(manager.sel_drawing(), None);
1186        manager.deselect();
1187        assert_eq!(manager.sel_drawing(), None);
1188        manager.drawings = (1..=3)
1189            .map(|id| Drawing::new(id, DrawingToolType::TrendLine))
1190            .collect();
1191        manager.drawings[2].selectable = false;
1192        manager.drawings[2].chart_points =
1193            vec![ChartPoint::new(0.0, 10.0), ChartPoint::new(100.0, 10.0)];
1194        manager.update_all_screen_coords(|x| x, |y| y as f32);
1195        assert_eq!(manager.hit_test(Pos2::new(50.0, 10.0)), None);
1196        assert_eq!(
1197            manager.hit_test_handle(&manager.drawings[2], Pos2::new(0.0, 10.0)),
1198            None
1199        );
1200        manager.select_drawings([1, 2, 3, 99]);
1201        assert_eq!(manager.selection.selection_count(), 2);
1202        manager.set_group(&[1, 2], Some("levels".into()));
1203        manager.select_group("levels");
1204        assert_eq!(manager.selected_ids().collect::<Vec<_>>(), [1, 2]);
1205        assert!(manager.undo());
1206        assert!(manager.group_drawings("levels").next().is_none());
1207        assert!(manager.redo());
1208        assert_eq!(manager.group_drawings("levels").count(), 2);
1209        manager.edit_selected(|drawing| {
1210            if drawing.id == 1 {
1211                drawing.selectable = false;
1212            }
1213        });
1214        assert_eq!(manager.sel_drawing(), Some(2));
1215        assert_eq!(manager.selected_ids().collect::<Vec<_>>(), [2]);
1216        assert!(manager.undo());
1217        manager.select_group("levels");
1218        manager.delete_selected();
1219        assert_eq!(
1220            manager.drawings.iter().map(|d| d.id).collect::<Vec<_>>(),
1221            [3]
1222        );
1223        assert_eq!(manager.sel_drawing(), None);
1224    }
1225
1226    /// A click on a Fibonacci level line, far from every control point, must
1227    /// select the drawing. This proves selection is routed through the unified
1228    /// per-level hit testing in the interaction service rather than the old
1229    /// vertex-proximity fallback that only matched near a control point.
1230    #[test]
1231    fn test_hit_test_fibonacci_level_off_control_point() {
1232        let mut manager = DrawingManager::new();
1233        let mut fib = Drawing::new(7, DrawingToolType::FibonacciRetracement);
1234        // Control points: Start (100,100), End (300,200), Middle (200,150).
1235        fib.chart_points = vec![ChartPoint::new(100.0, 100.0), ChartPoint::new(300.0, 200.0)];
1236        fib.update_screen_coords(|bar| bar, |price| price as f32);
1237        fib.completed = true;
1238        manager.drawings.push(fib);
1239
1240        // 0.236 level: y = 200 + (100 - 200) * 0.236 = 176.4, sampled at x = 180.
1241        // This point is more than 30 px from the Start, End and Middle handles.
1242        let click = Pos2::new(180.0, 176.4);
1243        for handle in [
1244            Pos2::new(100.0, 100.0),
1245            Pos2::new(300.0, 200.0),
1246            Pos2::new(200.0, 150.0),
1247        ] {
1248            let d = ((click.x - handle.x).powi(2) + (click.y - handle.y).powi(2)).sqrt();
1249            assert!(d > 20.0, "click must be off control points, was {d}px away");
1250        }
1251
1252        assert_eq!(manager.hit_test(click), Some(7));
1253        assert!(manager.select_at(click));
1254        assert_eq!(manager.sel_drawing(), Some(7));
1255    }
1256
1257    /// A click on a parallel-channel segment, far from every control point,
1258    /// must select the drawing -- again proving per-segment hit testing.
1259    #[test]
1260    fn test_hit_test_channel_segment_off_control_point() {
1261        let mut manager = DrawingManager::new();
1262        let mut channel = Drawing::new(11, DrawingToolType::ParallelChannel);
1263        // Main line p1 (100,100) -> p2 (300,100); offset point p3 (200,200).
1264        channel.points = vec![
1265            Pos2::new(100.0, 100.0),
1266            Pos2::new(300.0, 100.0),
1267            Pos2::new(200.0, 200.0),
1268        ];
1269        channel.completed = true;
1270        manager.drawings.push(channel);
1271
1272        // Midway along the main line, 50px+ from p1, p2, and p3.
1273        let click = Pos2::new(150.0, 100.0);
1274        for handle in [
1275            Pos2::new(100.0, 100.0),
1276            Pos2::new(300.0, 100.0),
1277            Pos2::new(200.0, 200.0),
1278        ] {
1279            let d = ((click.x - handle.x).powi(2) + (click.y - handle.y).powi(2)).sqrt();
1280            assert!(d > 20.0, "click must be off control points, was {d}px away");
1281        }
1282
1283        assert_eq!(manager.hit_test(click), Some(11));
1284    }
1285
1286    /// The magnet snaps to a drawing anchor, excluding members of the group being dragged.
1287    #[test]
1288    fn magnet_snaps_to_anchors_except_the_group_being_dragged() {
1289        let mut manager = DrawingManager::new();
1290        manager.options.magnet_mode = true;
1291        manager.update_snap_options();
1292
1293        let mut d = Drawing::new(1, DrawingToolType::TrendLine);
1294        d.points = vec![Pos2::new(100.0, 100.0), Pos2::new(200.0, 200.0)];
1295        let drawings = vec![d];
1296
1297        // A pointer near the first anchor snaps to it under magnet mode.
1298        let snapped = manager.snap_point(Pos2::new(103.0, 102.0), &drawings);
1299        assert_eq!(snapped, Pos2::new(100.0, 100.0));
1300
1301        // A group being translated cannot magnetize to another member's old position.
1302        manager.drawings = drawings;
1303        let mut peer = Drawing::new(2, DrawingToolType::TrendLine);
1304        peer.points = vec![Pos2::new(300.0, 300.0), Pos2::new(400.0, 400.0)];
1305        manager.drawings.push(peer);
1306        manager.select_drawings([1, 2]);
1307        manager.start_drag_body(1, Pos2::new(150.0, 150.0));
1308        let pointer = Pos2::new(303.0, 302.0);
1309        assert_eq!(manager.snap_point(pointer, &manager.drawings), pointer);
1310    }
1311
1312    /// A position's stop is an anchor of its own: it starts mirrored around
1313    /// the entry and moves without dragging the target along. The target and
1314    /// stop handles change price only; the width handle moves both to its x.
1315    #[test]
1316    fn position_stop_moves_independently_of_target() {
1317        let mut manager = DrawingManager::new();
1318        let x_to_bar = |x: f32| x;
1319        let y_to_price = |y: f32| f64::from(200.0 - y);
1320        manager.start_drawing_with_coords(
1321            DrawingToolType::LongPos,
1322            Pos2::new(10.0, 100.0),
1323            x_to_bar,
1324            y_to_price,
1325        );
1326        assert!(manager.curr_drawing.is_none());
1327        let id = manager.drawings[0].id;
1328        assert_eq!(manager.drawings[0].chart_points[2].price, 50.0);
1329
1330        manager.start_drag_handle(id, HandlePos::Point(2));
1331        manager.update_drag_handle(
1332            Pos2::new(50.0, 105.0),
1333            x_to_bar,
1334            y_to_price,
1335            |price, from, to| price - f64::from(to - from),
1336        );
1337        manager.end_drag_handle();
1338        let points = &manager.drawings[0].chart_points;
1339        assert_eq!((points[1].price, points[2].price), (150.0, 95.0));
1340        assert_eq!(points[2].bar_idx, 110.0);
1341
1342        manager.start_drag_handle(id, HandlePos::Point(1));
1343        manager.update_drag_handle(
1344            Pos2::new(40.0, 85.0),
1345            x_to_bar,
1346            y_to_price,
1347            |price, from, to| price - f64::from(to - from),
1348        );
1349        manager.end_drag_handle();
1350        let points = &manager.drawings[0].chart_points;
1351        assert_eq!((points[1].bar_idx, points[1].price), (110.0, 115.0));
1352        assert_eq!((points[2].bar_idx, points[2].price), (110.0, 95.0));
1353
1354        manager.start_drag_handle(id, HandlePos::End);
1355        manager.update_drag_handle(
1356            Pos2::new(260.0, 30.0),
1357            x_to_bar,
1358            y_to_price,
1359            |price, from, to| price - f64::from(to - from),
1360        );
1361        manager.end_drag_handle();
1362        let points = &manager.drawings[0].chart_points;
1363        assert_eq!((points[1].bar_idx, points[1].price), (260.0, 115.0));
1364        assert_eq!((points[2].bar_idx, points[2].price), (260.0, 95.0));
1365    }
1366
1367    /// A host state with both a tool and the eraser leaves only the eraser on, as
1368    /// `set_eraser_mode` does.
1369    #[test]
1370    fn app_state_with_a_tool_and_the_eraser_leaves_only_the_eraser() {
1371        let mut manager = DrawingManager::new();
1372        manager.sync_from_app_state(&DrawingState {
1373            active_tool: Some(DrawingToolType::TrendLine),
1374            eraser_mode: true,
1375            ..DrawingState::default()
1376        });
1377        assert!(manager.is_eraser_mode());
1378        assert_eq!(manager.active_tool(), None);
1379    }
1380
1381    /// Every path that creates drawings adds only drawings that save: a drawing of a disabled
1382    /// tool, a cursor, a non-finite or unmappable anchor or too few anchors is left out, and the
1383    /// chart still saves.
1384    #[test]
1385    fn creation_paths_add_only_drawings_that_save() {
1386        use crate::drawings::ToolAvailability;
1387        fn line(tool: DrawingToolType, anchors: &[(f32, f64)]) -> Drawing {
1388            let mut drawing = Drawing::new(0, tool);
1389            drawing.chart_points = anchors
1390                .iter()
1391                .map(|&(bar, price)| ChartPoint::new(bar, price))
1392                .collect();
1393            drawing.completed = true;
1394            drawing
1395        }
1396        fn place(m: &mut DrawingManager, tool: DrawingToolType, x_to_bar: fn(f32) -> f32) {
1397            let y_to_price = |y: f32| 200.0 - f64::from(y);
1398            m.start_drawing_with_coords(tool, Pos2::new(10.0, 95.0), x_to_bar, y_to_price);
1399            m.add_point_with_coords(Pos2::new(20.0, 94.0), x_to_bar, y_to_price);
1400            m.complete_curr_drawing();
1401        }
1402        const TWO: &[(f32, f64)] = &[(1.0, 105.0), (2.0, 106.0)];
1403        const NAN: &[(f32, f64)] = &[(1.0, 105.0), (2.0, f64::NAN)];
1404        const ONE: &[(f32, f64)] = &[(1.0, 105.0)];
1405        type Create = fn(&mut DrawingManager);
1406        let cases: [(&str, bool, Create); 12] = [
1407            ("duplicate, disabled tool", true, |m| {
1408                m.duplicate_drawing(m.drawings[0].id)
1409            }),
1410            ("copy drag, disabled tool", true, |m| {
1411                let id = m.drawings[0].id;
1412                m.start_drag_body(id, Pos2::ZERO);
1413                m.copy_dragged_drawings();
1414                m.end_drag_handle();
1415            }),
1416            ("paste, disabled tool", true, |m| {
1417                m.paste_drawings(&m.drawings.clone())
1418            }),
1419            ("paste, cursor", false, |m| {
1420                m.paste_drawings(&[line(DrawingToolType::Eraser, TWO)])
1421            }),
1422            ("paste, non-finite anchor", false, |m| {
1423                m.paste_drawings(&[line(DrawingToolType::TrendLine, NAN)])
1424            }),
1425            ("paste, one anchor", false, |m| {
1426                m.paste_drawings(&[line(DrawingToolType::TrendLine, ONE)])
1427            }),
1428            ("sync, cursor", false, |m| {
1429                let _ = m.add_synced_drawing(line(DrawingToolType::Eraser, TWO));
1430            }),
1431            ("sync, non-finite anchor", false, |m| {
1432                let _ = m.add_synced_drawing(line(DrawingToolType::TrendLine, NAN));
1433            }),
1434            ("sync, one anchor", false, |m| {
1435                let _ = m.add_synced_drawing(line(DrawingToolType::TrendLine, ONE));
1436            }),
1437            ("pointer, cursor", false, |m| {
1438                place(m, DrawingToolType::Eraser, |x| x / 10.0)
1439            }),
1440            ("pointer, non-finite anchor", false, |m| {
1441                place(m, DrawingToolType::TrendLine, |_| f32::NAN)
1442            }),
1443            ("pointer, unmappable anchor", false, |m| {
1444                place(m, DrawingToolType::TrendLine, |x| x * 1e30)
1445            }),
1446        ];
1447        for (case, disable, create) in cases {
1448            let mut chart = crate::widget::test_frames::chart(&[100.0], &[110.0]);
1449            let id = chart
1450                .drawings_mut()
1451                .add_drawing(line(DrawingToolType::TrendLine, TWO))
1452                .unwrap();
1453            if disable {
1454                chart.drawings_mut().registry.set_availability(
1455                    DrawingToolType::TrendLine,
1456                    ToolAvailability {
1457                        visible: true,
1458                        enabled: false,
1459                    },
1460                );
1461            }
1462            create(chart.drawings_mut());
1463            let ids: Vec<_> = chart.drawings().drawings.iter().map(|d| d.id).collect();
1464            assert_eq!(ids, [id], "{case}");
1465            assert!(chart.save_drawings().is_ok(), "{case}");
1466        }
1467    }
1468}