Skip to main content

egui_charts/widget/
events.rs

1//! Host notifications from chart setters, history loads and rendered interaction.
2
3use super::{Chart, ChartElementId, SeriesId, VisibleTimeRange};
4use crate::chart::rendering::StudyValue;
5use crate::model::Bar;
6use chrono::{DateTime, Utc};
7use egui::{Modifiers, PointerButton, Pos2, Ui};
8
9/// A chart cursor position with the data a host legend, tooltip or context action reads.
10/// Identical whitespace slots compare equal, including their colours and host payloads.
11#[derive(Debug, Clone)]
12#[non_exhaustive]
13pub struct ChartPointer {
14    /// Nearest eligible slot's timestamp, respecting the time-scale index filters.
15    /// With both filters off, extrapolated into whitespace as in `visible_time_range`.
16    pub time: DateTime<Utc>,
17    /// Price on the main pane, or the value on the scale of the hovered pane or volume.
18    pub price: f64,
19    /// `MAIN`, `VOLUME`, or the first series or study of another hovered pane. An empty pane
20    /// reports no position.
21    pub series: SeriesId,
22    /// Nearest slot in the rendered series, counting elements on price-based chart types.
23    /// Negative or past the last bar in whitespace.
24    pub index: isize,
25    /// Pointer position in UI points, the coordinates of the chart's `Response::rect`.
26    pub pos: Pos2,
27    /// Pane counted from the top among the panes drawn, the price pane with its volume band
28    /// included: `0` unless panes sit above the price pane.
29    pub pane: usize,
30    /// Main series bar, or price-based element, at `index`, with its colours and payload: a
31    /// [whitespace](Bar::whitespace) bar in an empty slot, `None` in the whitespace before the
32    /// first bar or after the last.
33    pub bar: Option<Bar>,
34    /// Close of the last bar or element with a value before `index`, from which legends measure
35    /// the change.
36    pub previous_close: Option<f64>,
37    /// Every series added with `add_series` or `add_comparison` that has a bar at `time`,
38    /// including hidden series and their host payloads.
39    pub series_bars: Vec<(SeriesId, Bar)>,
40    /// Visible study plots at `index`, as [`Chart::study_values_at`] returns them, each with the
41    /// id of its study.
42    pub studies: Vec<(SeriesId, StudyValue)>,
43    /// The drawing, series or study line under the pointer, in the priority a click selects.
44    pub target: Option<PointerTarget>,
45    /// Modifier keys held during the event.
46    pub modifiers: Modifiers,
47    /// Device that produced the event.
48    pub kind: PointerKind,
49}
50
51impl PartialEq for ChartPointer {
52    fn eq(&self, other: &Self) -> bool {
53        self.time == other.time
54            && self.price == other.price
55            && self.series == other.series
56            && self.index == other.index
57            && self.pos == other.pos
58            && self.pane == other.pane
59            && self.previous_close == other.previous_close
60            && self.studies == other.studies
61            && self.target == other.target
62            && self.modifiers == other.modifiers
63            && self.kind == other.kind
64            && match (&self.bar, &other.bar) {
65                (Some(left), Some(right)) => same_bar(left, right),
66                (None, None) => true,
67                _ => false,
68            }
69            && self.series_bars.len() == other.series_bars.len()
70            && self.series_bars.iter().zip(&other.series_bars).all(
71                |((left_id, left), (right_id, right))| left_id == right_id && same_bar(left, right),
72            )
73    }
74}
75
76fn same_bar(left: &Bar, right: &Bar) -> bool {
77    // Empty slots contain NaNs, but do not change while the pointer stays still.
78    left == right
79        || (left.is_whitespace()
80            && right.is_whitespace()
81            && left.time == right.time
82            && left.colors == right.colors
83            && left.payload == right.payload)
84}
85
86/// The object under the pointer of a [`ChartPointer`].
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88#[non_exhaustive]
89pub enum PointerTarget {
90    /// A series or study line, as a click there would select it.
91    Element(ChartElementId),
92    /// A drawing, by its drawing id.
93    Drawing(usize),
94    /// A primitive whose [`hit_test`](super::Primitive::hit_test) reports the pointer.
95    Primitive(super::PrimitiveId),
96}
97
98/// The device behind a [`ChartPointer`].
99///
100/// egui does not distinguish pens. Hosts with device information use
101/// [`Chart::set_pointer_kind`] before showing the chart.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103#[non_exhaustive]
104pub enum PointerKind {
105    /// A mouse, trackpad or other hovering pointer.
106    Mouse,
107    /// A finger on a touch surface, or an unidentified pen from egui.
108    Touch,
109    /// A pen identified by the host input integration.
110    Pen,
111}
112
113/// An accepted host data operation, reported by [`ChartEvent::DataOperation`].
114/// Update times are the incoming timestamps, matched using the chart's bar slots.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116#[non_exhaustive]
117pub enum DataOperation {
118    /// A full replacement through [`Chart::set_data`], including an empty or unchanged load.
119    SetData,
120    /// An [`update`](Chart::update) beyond the last stored slot, or into an empty series.
121    Append(DateTime<Utc>),
122    /// An [`update`](Chart::update) to an existing slot, including an unchanged bar.
123    Update(DateTime<Utc>),
124    /// An [`update`](Chart::update) inserted into a missing slot before the last stored slot.
125    Insert(DateTime<Utc>),
126}
127
128/// A notification for the host. Existing selection and action polling APIs remain independent.
129#[derive(Debug, Clone, PartialEq)]
130#[non_exhaustive]
131pub enum ChartEvent {
132    /// Oldest notifications dropped because the host did not drain the 1024-entry queue.
133    /// Emitted first on the next drain; read the current chart state to recover.
134    EventsDropped(usize),
135    /// The UTC plot edges changed; `None` means there is no data to map.
136    VisibleRangeChanged(Option<VisibleTimeRange>),
137    /// The visible logical range changed, as [`Chart::visible_logical_range`] returns it,
138    /// including changes of bar spacing alone; `None` means there is no data.
139    VisibleLogicalRangeChanged(Option<crate::model::LogicalRange>),
140    /// The time axis changed size, as [`Chart::time_scale_size`] returns it, including its
141    /// first non-zero size.
142    TimeScaleResized(egui::Vec2),
143    /// The local cursor moved or anything its pointer carries changed, such as the bar under a
144    /// stationary cursor or a held modifier; `None` means it left the plots or no eligible
145    /// crosshair index remains in view.
146    /// Programmatically synchronized cursors do not emit this event.
147    CrosshairMoved(Option<ChartPointer>),
148    /// A primary or secondary click in a plot, including clicks selecting drawings or studies.
149    Click {
150        /// Click location in the pane's units.
151        position: ChartPointer,
152        /// The button released to complete the click.
153        button: PointerButton,
154    },
155    /// A primary double click in a plot, after the [`Self::Click`] of each of its clicks.
156    DoubleClick {
157        /// Location of the second click.
158        position: ChartPointer,
159    },
160    /// A primary click on a bar mark with a host ID. Of overlapping marks the one painted on
161    /// top is reported, and the click selects nothing else.
162    MarkClick {
163        /// The [`Marker::id`](crate::model::Marker::id) the host gave the mark.
164        id: String,
165        /// The series the mark belongs to; [`SeriesId::MAIN`] for the main series.
166        series: SeriesId,
167        /// The time of the mark; on price-based chart types, of the element it sits on.
168        time: DateTime<Utc>,
169    },
170    /// A primary click on the Open row of a post or idea card with a link. Open the post or
171    /// idea it names; the link may be any text the user typed, so check it, such as its
172    /// scheme, before opening it. The click also selects the card, as any click on it does.
173    #[cfg(feature = "drawings")]
174    #[cfg_attr(docsrs, doc(cfg(feature = "drawings")))]
175    LinkClick {
176        /// Id of the drawing.
177        drawing: usize,
178        /// Its [`PostLink::link`](crate::model::PostLink::link).
179        link: String,
180    },
181    /// The user placed an image or dropped picture files on the price pane. Answer with
182    /// [`Chart::set_drawing_image`]; until then the image shows its placeholder.
183    #[cfg(feature = "drawings")]
184    #[cfg_attr(docsrs, doc(cfg(feature = "drawings")))]
185    ImageRequested(ImageRequest),
186    /// A successful history page or `set_data` completed, including an empty result.
187    /// Live updates emit `DataUpdated` instead; failed or obsolete feed replies emit nothing.
188    DataLoaded {
189        /// Bar series that received data.
190        series: SeriesId,
191        /// Total accepted bars now held by that series, after cleaning and merging.
192        bars: usize,
193    },
194    /// A live bar from `update` or the chart's feed changed the bars of a series, or `pop_bars`
195    /// removed some. Changes of one series in a row are reported once, with the latest count.
196    DataUpdated {
197        /// Bar series whose bars changed.
198        series: SeriesId,
199        /// Total bars now held by that series.
200        bars: usize,
201    },
202    /// One accepted `set_data` or `update` call, when enabled with
203    /// [`Chart::set_data_operation_notifications`]. Never coalesced; additional to the compact
204    /// `DataLoaded` and `DataUpdated` events. Feed replies, removals and constructor data do not
205    /// emit this event. Overflow is reported by `EventsDropped` as for other notifications.
206    DataOperation {
207        /// Bar series that accepted the operation.
208        series: SeriesId,
209        /// The operation and, for an update, its incoming bar time.
210        operation: DataOperation,
211        /// Total bars now held in the series' current window, after cleaning and retention.
212        bars: usize,
213    },
214    /// The symbol setter accepted a different symbol.
215    SymbolChanged(String),
216    /// The interval setter accepted a different interval.
217    TimeframeChanged(crate::model::Timeframe),
218    /// An added bar series joined the chart: through [`Chart::add_series`] or
219    /// [`Chart::add_comparison`], a restored snapshot, or undo or redo bringing back a series.
220    /// Undo and redo keep its id, options, visibility, pane, scale, markers, price lines,
221    /// price formatter, bar limit and bars, under the current replay cutoff. Supply current
222    /// bars with [`Chart::set_data`] and resume [`Chart::update`]. Snapshot restore assigns
223    /// new ids and leaves supplying bars to the host.
224    SeriesAdded(SeriesId),
225    /// An added bar series left the chart: through [`Chart::remove_series`] or
226    /// [`Chart::remove_pane`], the pane menu, a restored snapshot, or undo or redo of the edit
227    /// that added it. Data sent for it is ignored from then on. Undo or redo can bring it back
228    /// with the same id, so keep what the host needs to feed it again.
229    SeriesRemoved(SeriesId),
230}
231
232/// An image drawing waiting for its picture, from [`ChartEvent::ImageRequested`].
233///
234/// The chart reads no file and opens no dialog. For a placed image, `file` is `None` and the
235/// host may offer a file chooser; for a dropped file it holds egui's handle, which gives the
236/// path on native platforms and the bytes on the web. Treat the file as untrusted: check its
237/// size, format and pixel dimensions before loading it, then call [`Chart::set_drawing_image`]
238/// with an address the host's image loaders resolve.
239#[cfg(feature = "drawings")]
240#[cfg_attr(docsrs, doc(cfg(feature = "drawings")))]
241#[derive(Debug, Clone)]
242#[non_exhaustive]
243pub struct ImageRequest {
244    /// Id of the image drawing to answer.
245    pub drawing: usize,
246    /// The dropped file, or `None` when the user placed the image with the tool.
247    pub file: Option<egui::DroppedFileHandle>,
248}
249
250#[cfg(feature = "drawings")]
251impl PartialEq for ImageRequest {
252    fn eq(&self, other: &Self) -> bool {
253        self.drawing == other.drawing
254            && match (&self.file, &other.file) {
255                (None, None) => true,
256                (Some(a), Some(b)) => std::sync::Arc::ptr_eq(a, b),
257                _ => false,
258            }
259    }
260}
261
262#[derive(Default)]
263pub(super) struct HostEvents {
264    queue: std::collections::VecDeque<ChartEvent>,
265    dropped: usize,
266    pub(super) data_operations: bool,
267    range: Option<VisibleTimeRange>,
268    logical_range: Option<crate::model::LogicalRange>,
269    time_scale_size: egui::Vec2,
270    pointer: Option<ChartPointer>,
271    kind: Option<PointerKind>,
272    click_frames: [Option<u64>; 3],
273    /// What the pointer is over this frame, resolved while the plots are drawn.
274    pub target: Option<PointerTarget>,
275    pub volume_map: Option<crate::scales::PriceMap>,
276    #[cfg(feature = "drawings")]
277    dropped_images: Vec<egui::DroppedFileHandle>,
278}
279
280impl HostEvents {
281    pub(super) fn push(&mut self, event: ChartEvent) {
282        if self.queue.len() == 1024 {
283            self.queue.pop_front();
284            self.dropped = self.dropped.saturating_add(1);
285        }
286        self.queue.push_back(event);
287    }
288}
289
290impl Chart {
291    /// Enables an additional [`ChartEvent::DataOperation`] for each accepted
292    /// [`set_data`](Self::set_data) or [`update`](Self::update) call, in call order, including
293    /// unchanged data. Disabled by default; changing this setting affects future calls and keeps
294    /// already queued events.
295    /// Compact state-change notifications remain available in either mode.
296    ///
297    /// Invalid updates emit no operation event and increment
298    /// [`FeedState::rejected_bars`](crate::data::FeedState::rejected_bars).
299    /// A full replacement still emits one after dropping invalid bars, even if none remain.
300    /// Unknown, removed, volume and study series accept no data and emit no operation event.
301    /// During replay, updates are classified against the retained history, including future bars.
302    /// Feed notifications keep their existing batch semantics; this setting observes direct calls.
303    pub fn set_data_operation_notifications(&mut self, enabled: bool) {
304        self.host_events.data_operations = enabled;
305    }
306
307    /// Overrides the input device reported by pointer events. Set this before `show` when
308    /// the host knows the active device, including pens that egui cannot identify. `None`
309    /// restores automatic mouse/touch detection. Update it when the active device changes.
310    pub fn set_pointer_kind(&mut self, kind: Option<PointerKind>) {
311        self.host_events.kind = kind;
312    }
313
314    /// Drains host notifications in occurrence order. Call after `show` each frame.
315    ///
316    /// Symbol, interval, data and series setters enqueue immediately; view and pointer changes are
317    /// measured during `show`. Unchanged views and pointers emit nothing. Drain
318    /// regularly: the newest 1024 notifications remain queued until taken; overflow is
319    /// reported as `EventsDropped` before the retained events. A new chart's constructor data
320    /// emits no load event. This queue does not consume context, quote or selection actions.
321    ///
322    /// ```
323    /// # use egui_charts::{model::BarData, widget::{Chart, ChartEvent}};
324    /// # let mut chart = Chart::new(BarData::new());
325    /// # egui::__run_test_ui(|ui| {
326    /// chart.show(ui);
327    /// for event in chart.drain_events() {
328    ///     if let ChartEvent::Click { position, .. } = event {
329    ///         // Route the time and price to host-owned application state.
330    ///         let _ = (position.time, position.price);
331    ///     }
332    /// }
333    /// # });
334    /// ```
335    pub fn drain_events(&mut self) -> impl Iterator<Item = ChartEvent> + '_ {
336        let dropped = std::mem::take(&mut self.host_events.dropped);
337        (dropped > 0)
338            .then_some(ChartEvent::EventsDropped(dropped))
339            .into_iter()
340            .chain(self.host_events.queue.drain(..))
341    }
342
343    pub(super) fn data_loaded(&mut self, series: SeriesId) {
344        self.host_events.push(ChartEvent::DataLoaded {
345            series,
346            bars: self.bars(series).len(),
347        });
348    }
349
350    pub(super) fn data_set(&mut self, series: SeriesId) {
351        self.data_loaded(series);
352        self.data_operation(series, DataOperation::SetData);
353    }
354
355    pub(super) fn data_operation(&mut self, series: SeriesId, operation: DataOperation) {
356        if self.host_events.data_operations {
357            self.host_events.push(ChartEvent::DataOperation {
358                series,
359                operation,
360                bars: self.bars(series).len(),
361            });
362        }
363    }
364
365    pub(super) fn data_updated(&mut self, series: SeriesId) {
366        let bars = self.bars(series).len();
367        if let Some(ChartEvent::DataUpdated {
368            series: last,
369            bars: count,
370        }) = self
371            .host_events
372            .queue
373            .iter_mut()
374            .rev()
375            .find(|event| !matches!(event, ChartEvent::DataOperation { .. }))
376            && *last == series
377        {
378            *count = bars;
379        } else {
380            self.host_events
381                .push(ChartEvent::DataUpdated { series, bars });
382        }
383    }
384
385    fn pointer_at(&self, ui: &Ui, pos: Pos2) -> Option<ChartPointer> {
386        let plot = self.last_rendered_price_rect;
387        let shown = self.state.shown();
388        let coords = self.get_chart_mapping();
389        let selected = self.crosshair_position(pos, &coords)?;
390        let index = coords.x_to_idx_f64(selected.x).round();
391        let time = crate::model::time_at(shown, index)?;
392        let bar_at = (index >= 0.0 && index < shown.len() as f64).then_some(index as usize);
393        let magnet = |map: crate::scales::PriceMap, values: Vec<f64>| {
394            if self.chart_options.crosshair.mode.snaps() {
395                crate::chart::rendering::snap_to_values(pos.y, &map, values)
396                    .unwrap_or_else(|| map.y_to_price(pos.y))
397            } else {
398                map.y_to_price(pos.y)
399            }
400        };
401        // Panes are counted as drawn, from the top; the price pane may sit below others.
402        let pane_index = |id: super::PaneId| {
403            (self.pane_layout.panes.iter())
404                .filter(|pane| pane.id == super::PaneId::MAIN || self.pane_shown(pane))
405                .position(|pane| pane.id == id)
406                .unwrap_or_default()
407        };
408        let (series, price, pane) = if let Some(group) =
409            self.pane_layout.panes.iter().find(|group| {
410                group.id != super::PaneId::MAIN
411                    && group.rect.contains(pos)
412                    && plot.x_range().contains(pos.x)
413            }) {
414            let owner = *self.pane_series(group.id).first()?;
415            let map = group.price_map();
416            let closes = group.members.iter().filter_map(|id| {
417                let item =
418                    (self.price_series.iter()).find(|item| item.id == *id && item.visible)?;
419                bar_at.and_then(|index| self.series_close_at(item, index))
420            });
421            let values = group
422                .members
423                .iter()
424                .filter_map(|id| self.study_ids.iter().position(|study| study == id))
425                .map(|index| &self.rendered_studies().indicators()[index])
426                .filter(|study| study.is_visible())
427                .flat_map(|study| {
428                    (0..study.line_count())
429                        .filter(|line| {
430                            let plot = study.plot_style(*line);
431                            plot.visible
432                                && !matches!(
433                                    plot.plot_type,
434                                    crate::studies::PlotType::BarColor
435                                        | crate::studies::PlotType::BackgroundColor
436                                )
437                        })
438                        .filter_map(move |line| {
439                            bar_at.and_then(|index| study.plot_value(line, index))
440                        })
441                })
442                .chain(closes)
443                .collect::<Vec<_>>();
444            (owner, magnet(map, values), pane_index(group.id))
445        } else if self.last_rendered_volume_rect.contains(pos)
446            && let Some(map) = self.host_events.volume_map
447        {
448            let values = bar_at
449                .map(|index| shown[index].volume)
450                .into_iter()
451                .collect();
452            (
453                SeriesId::VOLUME,
454                magnet(map, values),
455                pane_index(super::PaneId::MAIN),
456            )
457        } else if plot.contains(pos) {
458            let snapped = bar_at.map_or(pos, |index| {
459                self.snap_crosshair(pos, &self.last_price_map, index)
460            });
461            let price = self.last_price_map.y_to_price(snapped.y);
462            (SeriesId::MAIN, price, pane_index(super::PaneId::MAIN))
463        } else {
464            return None;
465        };
466        price.is_finite().then(|| ChartPointer {
467            time,
468            price,
469            series,
470            index: index as isize,
471            pos,
472            pane,
473            bar: bar_at.map(|index| shown[index].clone()),
474            previous_close: bar_at
475                .and_then(|index| crate::model::last_with_value(&shown[..index]))
476                .map(|bar| bar.close),
477            series_bars: self
478                .price_series
479                .iter()
480                .filter_map(|item| {
481                    let at = item.bars.binary_search_by_key(&time, |bar| bar.time).ok()?;
482                    Some((item.id, item.bars[at].clone()))
483                })
484                .collect(),
485            studies: bar_at.map_or_else(Vec::new, |index| self.study_rows_at(index)),
486            target: self.host_events.target,
487            modifiers: ui.input(|input| input.modifiers),
488            kind: self.host_events.kind.unwrap_or_else(|| {
489                if ui.input(|input| {
490                    input.any_touches()
491                        || input
492                            .events
493                            .iter()
494                            .any(|event| matches!(event, egui::Event::Touch { .. }))
495                }) {
496                    PointerKind::Touch
497                } else {
498                    PointerKind::Mouse
499                }
500            }),
501        })
502    }
503
504    pub(super) fn record_host_events(&mut self, ui: &Ui) {
505        #[cfg(feature = "drawings")]
506        self.record_image_requests(ui);
507        let range = self.visible_time_range();
508        if range != self.host_events.range {
509            self.host_events.range = range;
510            self.host_events
511                .push(ChartEvent::VisibleRangeChanged(range));
512        }
513        let logical_range = self.visible_logical_range();
514        if logical_range != self.host_events.logical_range {
515            self.host_events.logical_range = logical_range;
516            self.host_events
517                .push(ChartEvent::VisibleLogicalRangeChanged(logical_range));
518        }
519        let size = self.time_scale_size();
520        if size != self.host_events.time_scale_size {
521            self.host_events.time_scale_size = size;
522            self.host_events.push(ChartEvent::TimeScaleResized(size));
523        }
524        let position = ui.input(|input| input.pointer.hover_pos()).and_then(|pos| {
525            ui.rect_contains_pointer(egui::Rect::from_min_max(
526                egui::pos2(self.last_rendered_price_rect.left(), ui.max_rect().top()),
527                egui::pos2(
528                    self.last_rendered_price_rect.right(),
529                    ui.max_rect().bottom(),
530                ),
531            ))
532            .then(|| self.pointer_at(ui, pos))
533            .flatten()
534        });
535        if position != self.host_events.pointer {
536            self.host_events.pointer = position.clone();
537            self.host_events.push(ChartEvent::CrosshairMoved(position));
538        }
539    }
540
541    #[cfg(feature = "drawings")]
542    fn record_image_requests(&mut self, ui: &Ui) {
543        use crate::drawings::DrawingToolType;
544
545        if let Some(drawing) = self.drawings.image_request.take() {
546            self.host_events
547                .push(ChartEvent::ImageRequested(ImageRequest {
548                    drawing,
549                    file: None,
550                }));
551        }
552        let pos = ui.input(|input| {
553            for file in &input.raw.dropped_files {
554                let supported = file
555                    .path()
556                    .extension()
557                    .and_then(|ext| ext.to_str())
558                    .is_some_and(|ext| {
559                        ["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"]
560                            .iter()
561                            .any(|image| ext.eq_ignore_ascii_case(image))
562                    });
563                if supported && self.host_events.dropped_images.len() < 16 {
564                    self.host_events.dropped_images.push(file.clone());
565                }
566            }
567            input.pointer.hover_pos()
568        });
569        // shortcut: egui has no drop position; hidden charts retain files until shown with a
570        // pointer. Use the drop position instead when egui supplies it.
571        let Some(pos) = pos.filter(|_| !self.host_events.dropped_images.is_empty()) else {
572            return;
573        };
574        let files = std::mem::take(&mut self.host_events.dropped_images);
575        let plot = self.last_rendered_price_rect;
576        if !ui.rect_contains_pointer(plot) {
577            return;
578        }
579        let index = self
580            .state
581            .time_scale()
582            .coord_to_idx_precise(pos.x, plot.left(), plot.width())
583            .round();
584        let Some(time) = crate::model::time_at(self.state.shown(), index) else {
585            return;
586        };
587        let price = self.last_price_map.y_to_price(pos.y);
588        if !price.is_finite() {
589            return;
590        }
591        let requests = self.user_action(|chart| {
592            files
593                .into_iter()
594                .filter_map(|file| {
595                    let mut drawing = chart.drawings.new_drawing(0, DrawingToolType::Image);
596                    drawing.z_order = chart.drawings.top_z_order();
597                    let drawing = chart.create_drawing(drawing, &[(time, price)]).ok()?;
598                    Some(ImageRequest {
599                        drawing,
600                        file: Some(file),
601                    })
602                })
603                .collect::<Vec<_>>()
604        });
605        if let Some(request) = requests.last() {
606            self.drawings.select(request.drawing);
607        }
608        for request in requests {
609            self.host_events.push(ChartEvent::ImageRequested(request));
610        }
611    }
612
613    pub(super) fn record_host_click(&mut self, ui: &Ui, response: &egui::Response) {
614        let Some(position) = response
615            .interact_pointer_pos()
616            .and_then(|pos| self.pointer_at(ui, pos))
617        else {
618            return;
619        };
620        let frame = ui.ctx().cumulative_frame_nr();
621        for (index, button) in [PointerButton::Primary, PointerButton::Secondary]
622            .into_iter()
623            .enumerate()
624        {
625            if response.clicked_by(button) && self.host_events.click_frames[index] != Some(frame) {
626                self.host_events.click_frames[index] = Some(frame);
627                self.host_events.push(ChartEvent::Click {
628                    position: position.clone(),
629                    button,
630                });
631                if button == PointerButton::Primary {
632                    self.click_mark(position.pos);
633                    // The second click of a double click edits the card; the first opened it.
634                    #[cfg(feature = "drawings")]
635                    if !response.double_clicked() && !response.triple_clicked() {
636                        self.click_link(ui.ctx(), position.pos);
637                    }
638                }
639            }
640        }
641        if response.double_clicked() && self.host_events.click_frames[2] != Some(frame) {
642            self.host_events.click_frames[2] = Some(frame);
643            self.host_events.push(ChartEvent::DoubleClick { position });
644        }
645    }
646}
647
648#[cfg(test)]
649mod tests {
650    /// Placement, delayed drops and host answers retain their ids, coordinates and undo steps.
651    #[cfg(feature = "drawings")]
652    #[test]
653    fn image_requests_reach_the_host() {
654        use crate::drawings::{Drawing, DrawingToolType};
655        use crate::widget::test_frames::{chart, frame};
656        use egui::{Context, Event, RawInput, Rect, Vec2, pos2, vec2};
657        use std::sync::Arc;
658
659        #[derive(Debug)]
660        struct File(&'static str);
661        impl egui::DroppedFile for File {
662            fn path(&self) -> &std::path::Path {
663                self.0.as_ref()
664            }
665            #[cfg(not(target_arch = "wasm32"))]
666            fn bytes(&self) -> Result<Vec<u8>, String> {
667                Ok(Vec::new())
668            }
669            #[cfg(target_arch = "wasm32")]
670            fn bytes_async(
671                &self,
672            ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<u8>, String>> + '_>>
673            {
674                Box::pin(async { Ok(Vec::new()) })
675            }
676        }
677        let requests = |chart: &mut Chart| {
678            chart
679                .drain_events()
680                .filter_map(|event| {
681                    if let ChartEvent::ImageRequested(request) = event {
682                        Some(request)
683                    } else {
684                        None
685                    }
686                })
687                .collect::<Vec<_>>()
688        };
689        let ctx = Context::default();
690        let mut chart = chart(&[10.0], &[20.0]);
691        frame(&ctx, &mut chart, 0.0, vec![]);
692        let map = chart.get_chart_mapping();
693        let p = pos2(map.idx_to_x(30), map.price_to_y(16.0));
694        chart
695            .drawings_mut()
696            .set_active_tool(Some(DrawingToolType::Image));
697        frame(&ctx, &mut chart, 0.1, vec![Event::PointerMoved(p)]);
698        for (time, pressed) in [(0.2, true), (0.3, false)] {
699            frame(
700                &ctx,
701                &mut chart,
702                time,
703                vec![Event::PointerButton {
704                    pos: p,
705                    button: PointerButton::Primary,
706                    pressed,
707                    modifiers: Modifiers::NONE,
708                }],
709            );
710        }
711        let id = chart.drawings().drawings[0].id;
712        assert_eq!(
713            requests(&mut chart),
714            [ImageRequest {
715                drawing: id,
716                file: None
717            }]
718        );
719        assert_eq!(chart.drawings().text_editor.as_ref().unwrap().id, id);
720        assert_eq!(
721            chart.set_drawing_image(id, "test://a", vec2(1920.0, 1080.0)),
722            Ok(())
723        );
724        let picture = &chart.drawings().drawings[0];
725        assert_eq!(picture.text.as_deref(), Some("test://a"));
726        assert_eq!(picture.image.unwrap().size, Some([240.0, 135.0]));
727        assert!(chart.drawings().text_editor.is_none());
728        assert!(chart.undo());
729        assert_eq!(chart.drawings().drawings[0].text, None);
730        assert_eq!(chart.drawings().drawings[0].image, None);
731        for (size, expected) in [
732            (vec2(100.0, 50.0), [100.0, 50.0]),
733            (vec2(400.0, 200.0), [240.0, 120.0]),
734            (vec2(480.0, 720.0), [120.0, 180.0]),
735            (vec2(10_000.0, 1.0), [240.0, 1.0]),
736        ] {
737            chart.set_drawing_image(id, "test://a", size).unwrap();
738            let actual = chart.drawings().drawings[0].image.unwrap().size.unwrap();
739            for (actual, expected) in actual.into_iter().zip(expected) {
740                assert!((actual - expected).abs() < 1e-3);
741            }
742            assert!(actual[0] <= 240.0 && actual[1] <= 180.0, "{actual:?}");
743            assert!(chart.undo());
744        }
745        let line = chart
746            .create_drawing(
747                Drawing::new(0, DrawingToolType::TrendLine),
748                &[
749                    (chart.bars(SeriesId::MAIN)[0].time, 15.0),
750                    (chart.bars(SeriesId::MAIN)[1].time, 16.0),
751                ],
752            )
753            .unwrap();
754        for (target, locked, uri, size, error) in [
755            (usize::MAX, false, "a", vec2(10.0, 10.0), "Unknown drawing"),
756            (id, true, "a", vec2(10.0, 10.0), "Drawing is locked"),
757            (
758                line,
759                false,
760                "a",
761                vec2(10.0, 10.0),
762                "Drawing is not an image",
763            ),
764            (id, false, "  ", vec2(10.0, 10.0), "Image address is empty"),
765            (
766                id,
767                false,
768                "a",
769                vec2(0.0, 10.0),
770                "Image size must be positive and finite",
771            ),
772            (
773                id,
774                false,
775                "a",
776                vec2(f32::NAN, 1.0),
777                "Image size must be positive and finite",
778            ),
779        ] {
780            chart.drawings_mut().drawings[0].locked = locked;
781            let before = chart.drawings().drawings.clone();
782            assert_eq!(chart.set_drawing_image(target, uri, size), Err(error));
783            assert_eq!(
784                serde_json::to_value(&chart.drawings().drawings).unwrap(),
785                serde_json::to_value(&before).unwrap()
786            );
787        }
788        chart.drawings_mut().drawings.clear();
789        frame(&ctx, &mut chart, 0.4, vec![Event::PointerGone]);
790        let files: Vec<egui::DroppedFileHandle> = vec![
791            Arc::new(File("a.PNG")),
792            Arc::new(File("b.jpg")),
793            Arc::new(File("notes.txt")),
794        ];
795        let drop_files = |chart: &mut Chart, time, dropped_files| {
796            let output = ctx.run_ui(
797                RawInput {
798                    screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0))),
799                    time: Some(time),
800                    dropped_files,
801                    ..Default::default()
802                },
803                |ui| {
804                    egui::CentralPanel::default().show(ui, |ui| {
805                        chart.show(ui);
806                    });
807                },
808            );
809            output.drop_without_applying_deltas();
810        };
811        drop_files(&mut chart, 0.5, files.clone());
812        assert!(chart.drawings().drawings.is_empty());
813        assert!(requests(&mut chart).is_empty());
814        frame(&ctx, &mut chart, 0.6, vec![Event::PointerMoved(p)]);
815        let dropped = requests(&mut chart);
816        assert_eq!(dropped.len(), 2);
817        assert_eq!(chart.drawings().drawings.len(), 2);
818        for (request, file) in dropped.iter().zip(&files) {
819            assert!(Arc::ptr_eq(request.file.as_ref().unwrap(), file));
820            let anchors = chart.drawing_points(request.drawing).unwrap();
821            assert_eq!(anchors.len(), 1);
822            assert_eq!(anchors[0].0.timestamp(), 1_702_592_000);
823            assert!((anchors[0].1 - 16.0).abs() < 1e-6, "{anchors:?}");
824        }
825        assert_eq!(chart.drawings().sel_drawing(), Some(dropped[1].drawing));
826        assert!(chart.undo());
827        assert!(chart.drawings().drawings.is_empty());
828        frame(&ctx, &mut chart, 0.7, vec![Event::PointerGone]);
829        drop_files(&mut chart, 0.8, vec![files[0].clone()]);
830        let rect = chart.get_rendered_price_rect();
831        frame(
832            &ctx,
833            &mut chart,
834            0.9,
835            vec![Event::PointerMoved(pos2(
836                rect.center().x,
837                rect.bottom() + 5.0,
838            ))],
839        );
840        assert!(chart.host_events.dropped_images.is_empty());
841        frame(&ctx, &mut chart, 1.0, vec![Event::PointerMoved(p)]);
842        assert!(chart.drawings().drawings.is_empty());
843        assert!(requests(&mut chart).is_empty());
844    }
845
846    use super::*;
847    use crate::widget::test_frames::{chart, frame};
848    use egui::{Context, Event};
849
850    /// Direct operations keep their kind and order, including unchanged updates, while compact
851    /// events still coalesce. Rejections stay silent and overflow reports every lost entry.
852    #[test]
853    fn data_operation_notifications_keep_accepted_setter_order() {
854        let start = "2024-01-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
855        let bar = |minute, price| {
856            Bar::new(
857                start + chrono::TimeDelta::minutes(minute),
858                price,
859                price,
860                price,
861                price,
862                1.0,
863            )
864        };
865        for secondary in [false, true] {
866            for replay in [false, true] {
867                for enabled in [false, true] {
868                    let mut chart = Chart::new(crate::model::BarData::new());
869                    let series = if secondary {
870                        chart.add_series(Default::default())
871                    } else {
872                        SeriesId::MAIN
873                    };
874                    if replay {
875                        chart.set_replay_time(Some(bar(2, 1.0).time));
876                    }
877                    chart.drain_events().for_each(drop);
878                    if enabled {
879                        chart.set_data_operation_notifications(true);
880                    }
881                    chart.set_data(series, vec![bar(0, 10.0), bar(2, 20.0)]);
882                    chart.update(series, bar(3, 30.0));
883                    chart.update(series, bar(3, 30.0));
884                    chart.update(series, bar(0, 11.0));
885                    chart.update(series, bar(1, 15.0));
886                    chart.update(series, bar(4, f64::INFINITY));
887                    chart.update(SeriesId(usize::MAX), bar(4, 40.0));
888                    chart.set_data(SeriesId(usize::MAX), vec![bar(4, 40.0)]);
889                    assert_eq!(chart.feed_state(series).unwrap().rejected_bars, 1);
890                    let events = chart.drain_events().collect::<Vec<_>>();
891                    let operations = events
892                        .iter()
893                        .filter(|event| matches!(event, ChartEvent::DataOperation { .. }))
894                        .cloned()
895                        .collect::<Vec<_>>();
896                    let operation = |operation, bars| ChartEvent::DataOperation {
897                        series,
898                        operation,
899                        bars,
900                    };
901                    let expected = if enabled {
902                        vec![
903                            operation(DataOperation::SetData, if replay { 1 } else { 2 }),
904                            operation(
905                                DataOperation::Append(bar(3, 30.0).time),
906                                if replay { 1 } else { 3 },
907                            ),
908                            operation(
909                                DataOperation::Update(bar(3, 30.0).time),
910                                if replay { 1 } else { 3 },
911                            ),
912                            operation(DataOperation::Update(start), if replay { 1 } else { 3 }),
913                            operation(
914                                DataOperation::Insert(bar(1, 15.0).time),
915                                if replay { 2 } else { 4 },
916                            ),
917                        ]
918                    } else {
919                        Vec::new()
920                    };
921                    assert_eq!(
922                        operations, expected,
923                        "secondary={secondary}, replay={replay}, enabled={enabled}"
924                    );
925                    let compact = events
926                        .into_iter()
927                        .filter(|event| !matches!(event, ChartEvent::DataOperation { .. }))
928                        .collect::<Vec<_>>();
929                    assert_eq!(
930                        compact,
931                        [
932                            ChartEvent::DataLoaded {
933                                series,
934                                bars: if replay { 1 } else { 2 }
935                            },
936                            ChartEvent::DataUpdated {
937                                series,
938                                bars: if replay { 2 } else { 4 }
939                            },
940                        ]
941                    );
942                    chart.set_data(series, Vec::new());
943                    chart.update(series, bar(0, 10.0));
944                    chart.set_data_operation_notifications(false);
945                    let events = chart.drain_events().collect::<Vec<_>>();
946                    assert_eq!(events.len(), if enabled { 4 } else { 2 });
947                    if enabled {
948                        assert_eq!(events[1], operation(DataOperation::SetData, 0));
949                        assert_eq!(events[3], operation(DataOperation::Append(start), 1));
950                    }
951                    chart.update(series, bar(0, 10.0));
952                    assert_eq!(chart.drain_events().count(), 0);
953                    chart.set_replay_time(None);
954                    chart.set_data_operation_notifications(true);
955                    for _ in 0..1100 {
956                        chart.update(series, bar(0, 10.0));
957                    }
958                    let events = chart.drain_events().collect::<Vec<_>>();
959                    assert_eq!(events.len(), 1025);
960                    assert_eq!(events[0], ChartEvent::EventsDropped(76));
961                    assert!(
962                        events[1..]
963                            .iter()
964                            .all(|event| *event == operation(DataOperation::Update(start), 1))
965                    );
966                }
967            }
968        }
969    }
970
971    /// Hosts receive changes once, retain setter order, and get UTC and raw prices rather
972    /// than scale coordinates. Polling selection or draining twice cannot consume a click.
973    #[test]
974    fn host_events_report_changes_and_plot_coordinates_once() {
975        let ctx = Context::default();
976        let mut chart = chart(&[100.0], &[200.0]);
977        assert_eq!(chart.drain_events().count(), 0);
978        for i in 0..1100 {
979            chart.set_symbol(i.to_string());
980        }
981        let events = chart.drain_events().collect::<Vec<_>>();
982        assert_eq!(events.len(), 1025);
983        assert_eq!(events[0], ChartEvent::EventsDropped(76));
984        assert_eq!(
985            events.last(),
986            Some(&ChartEvent::SymbolChanged("1099".into()))
987        );
988        chart.set_symbol("A");
989        chart.set_symbol("A");
990        chart.set_timeframe(crate::model::Timeframe::Day1);
991        chart.set_timeframe(crate::model::Timeframe::Day1);
992        let mut bars = chart.bars(SeriesId::MAIN).to_vec();
993        // A payload comes back with its bar, and a whitespace bar keeps its slot and is skipped
994        // for the previous close.
995        bars[70].payload = Some(std::sync::Arc::new(serde_json::json!({ "id": 70 })));
996        bars[69] = Bar::whitespace(bars[69].time);
997        chart.set_data(SeriesId::MAIN, bars.clone());
998        chart.update(SeriesId::MAIN, bars[99].clone());
999        assert_eq!(
1000            chart.drain_events().collect::<Vec<_>>(),
1001            vec![
1002                ChartEvent::SymbolChanged("A".into()),
1003                ChartEvent::TimeframeChanged(crate::model::Timeframe::Day1),
1004                ChartEvent::DataLoaded {
1005                    series: SeriesId::MAIN,
1006                    bars: 100
1007                },
1008            ]
1009        );
1010        chart.config.show_volume = false;
1011        chart.config.show_ohlc_info = false;
1012        chart.config.show_symbol_labels = false;
1013        for t in [0.0, 0.1, 0.2] {
1014            frame(&ctx, &mut chart, t, vec![]);
1015        }
1016        assert!(
1017            chart
1018                .drain_events()
1019                .any(|event| matches!(event, ChartEvent::VisibleRangeChanged(Some(_))))
1020        );
1021        frame(&ctx, &mut chart, 0.3, vec![]);
1022        assert_eq!(chart.drain_events().count(), 0);
1023        // With a manual linear 100..200 range, a quarter of the way from bottom is 125.
1024        // The same fraction on an inverted map is 175; on a logarithmic map it is 100*2^0.25.
1025        // Bars open at their low of 100 and close at their high of 200, so the magnet takes
1026        // the nearer 100 and the close magnet 200.
1027        use crate::config::CrosshairMode;
1028        for (mode, inverted, crosshair, price) in [
1029            (
1030                crate::scales::PriceScaleMode::Normal,
1031                false,
1032                CrosshairMode::Normal,
1033                125.0,
1034            ),
1035            (
1036                crate::scales::PriceScaleMode::Normal,
1037                true,
1038                CrosshairMode::Normal,
1039                175.0,
1040            ),
1041            (
1042                crate::scales::PriceScaleMode::Logarithmic,
1043                false,
1044                CrosshairMode::Normal,
1045                100.0 * 2.0_f64.powf(0.25),
1046            ),
1047            (
1048                crate::scales::PriceScaleMode::Normal,
1049                false,
1050                CrosshairMode::Magnet,
1051                100.0,
1052            ),
1053            (
1054                crate::scales::PriceScaleMode::Normal,
1055                false,
1056                CrosshairMode::MagnetClose,
1057                200.0,
1058            ),
1059        ] {
1060            chart.set_crosshair_mode(crosshair);
1061            let scale = chart.state.price_scale_mut();
1062            scale.options_mut().mode = mode;
1063            scale.options_mut().invert_scale = inverted;
1064
1065            let transform = crate::scales::PriceTransform { mode, base: 0.0 };
1066            scale.rebase(transform);
1067            scale.set_range(transform.to_scale(100.0), transform.to_scale(200.0));
1068            frame(&ctx, &mut chart, 1.0, vec![]);
1069            chart.drain_events().for_each(drop);
1070            let rect = chart.last_rendered_price_rect;
1071            let pos = Pos2::new(
1072                chart.get_chart_mapping().idx_to_x(70),
1073                rect.bottom() - rect.height() * 0.25,
1074            );
1075            frame(&ctx, &mut chart, 1.1, vec![Event::PointerMoved(pos)]);
1076            let events = chart.drain_events().collect::<Vec<_>>();
1077            let Some(ChartEvent::CrosshairMoved(Some(pointer))) = events.last() else {
1078                panic!("{events:?}")
1079            };
1080            assert_eq!(pointer.time, bars[70].time);
1081            assert_eq!(pointer.series, SeriesId::MAIN);
1082            assert!(
1083                (pointer.price - price).abs() < 0.001,
1084                "{} != {price}",
1085                pointer.price
1086            );
1087            // Each candle spans 100..200, so the pointer is over the main series.
1088            assert_eq!((pointer.index, pointer.pos, pointer.pane), (70, pos, 0));
1089            assert_eq!(pointer.bar.as_ref(), Some(&bars[70]));
1090            assert_eq!(pointer.previous_close, Some(bars[68].close));
1091            assert!(pointer.series_bars.is_empty() && pointer.studies.is_empty());
1092            assert_eq!(
1093                pointer.target,
1094                Some(PointerTarget::Element(ChartElementId::Series(
1095                    SeriesId::MAIN
1096                )))
1097            );
1098            assert_eq!(
1099                (pointer.modifiers, pointer.kind),
1100                (Modifiers::NONE, PointerKind::Mouse)
1101            );
1102            let expected = pointer.clone();
1103            frame(&ctx, &mut chart, 1.2, vec![]);
1104            assert_eq!(chart.drain_events().count(), 0);
1105            for button in [PointerButton::Primary, PointerButton::Secondary] {
1106                for pressed in [true, false] {
1107                    frame(
1108                        &ctx,
1109                        &mut chart,
1110                        1.3,
1111                        vec![Event::PointerButton {
1112                            pos,
1113                            button,
1114                            pressed,
1115                            modifiers: Default::default(),
1116                        }],
1117                    );
1118                }
1119                assert!(chart.drain_events().any(|event| event
1120                    == ChartEvent::Click {
1121                        position: expected.clone(),
1122                        button
1123                    }));
1124                assert_eq!(chart.drain_events().count(), 0);
1125            }
1126            frame(&ctx, &mut chart, 1.4, vec![Event::PointerGone]);
1127            assert!(
1128                chart
1129                    .drain_events()
1130                    .any(|event| event == ChartEvent::CrosshairMoved(None))
1131            );
1132        }
1133        chart.set_crosshair_mode(CrosshairMode::Normal);
1134        #[cfg(feature = "studies")]
1135        {
1136            let id = chart.add_indicator(Box::new(crate::studies::RSI::default()));
1137            chart.sync_panes();
1138            let pane_id = chart.series_pane(id).unwrap();
1139            for (above, maximized) in [(false, false), (false, true), (true, true), (true, false)] {
1140                chart.move_pane(pane_id, if above { 0 } else { 1 });
1141                chart.pane_layout.maximized = maximized.then_some(pane_id);
1142                frame(&ctx, &mut chart, 1.5, vec![]);
1143                chart.drain_events().for_each(drop);
1144                let pane = &chart.last_rendered_indicator_panes[0];
1145                let pos = Pos2::new(pane.mapping.idx_to_x(70), pane.chart_rect.center().y);
1146                frame(&ctx, &mut chart, 1.6, vec![Event::PointerMoved(pos)]);
1147                let events = chart.drain_events().collect::<Vec<_>>();
1148                let Some(ChartEvent::CrosshairMoved(Some(pointer))) = events.last() else {
1149                    panic!("above={above}, maximized={maximized}: {events:?}")
1150                };
1151                assert_eq!(pointer.series, id);
1152                assert_eq!(pointer.time, bars[70].time);
1153                assert!((pointer.price - 50.0).abs() < 0.001);
1154                assert_eq!(pointer.pane, usize::from(!above));
1155                assert_eq!(
1156                    pointer.studies,
1157                    chart
1158                        .study_values_at(70)
1159                        .into_iter()
1160                        .map(|row| (id, row))
1161                        .collect::<Vec<_>>()
1162                );
1163                let expected = pointer.clone();
1164                for pressed in [true, false] {
1165                    frame(
1166                        &ctx,
1167                        &mut chart,
1168                        1.7,
1169                        vec![Event::PointerButton {
1170                            pos,
1171                            button: PointerButton::Primary,
1172                            pressed,
1173                            modifiers: Default::default(),
1174                        }],
1175                    );
1176                }
1177                let events = chart.drain_events().collect::<Vec<_>>();
1178                assert!(
1179                    events.iter().any(|event| *event
1180                        == ChartEvent::Click {
1181                            position: expected.clone(),
1182                            button: PointerButton::Primary
1183                        }),
1184                    "above={above}, maximized={maximized}: {events:?}, expected {expected:?}"
1185                );
1186                frame(&ctx, &mut chart, 1.75, vec![Event::PointerGone]);
1187            }
1188            chart.move_pane(pane_id, 1);
1189            let other = chart.add_indicator(Box::new(
1190                crate::studies::CustomIndicator::new("Other", |bars| {
1191                    bars.iter()
1192                        .map(|_| crate::studies::IndicatorValue::Single(75.0))
1193                        .collect()
1194                })
1195                .with_overlay(false),
1196            ));
1197            assert!(chart.move_indicator_to_pane(other, id));
1198            chart.set_crosshair_mode(crate::config::CrosshairMode::Magnet);
1199            frame(&ctx, &mut chart, 1.8, vec![Event::PointerGone]);
1200            chart.drain_events().for_each(drop);
1201            let pane = &chart.last_rendered_indicator_panes[0];
1202            let pos = Pos2::new(pane.mapping.idx_to_x(70), pane.mapping.price_to_y(74.0));
1203            frame(&ctx, &mut chart, 1.9, vec![Event::PointerMoved(pos)]);
1204            let events = chart.drain_events().collect::<Vec<_>>();
1205            assert!(events.iter().any(|event| matches!(event, ChartEvent::CrosshairMoved(Some(point)) if point.series == id && (point.price - 75.0).abs() < 0.001)), "{events:?}");
1206            let pane = &chart.last_rendered_indicator_panes[0];
1207            let pos = Pos2::new(pane.mapping.idx_to_x(70), pane.mapping.price_to_y(75.0));
1208            frame(&ctx, &mut chart, 1.95, vec![Event::PointerMoved(pos)]);
1209            let events = chart.drain_events().collect::<Vec<_>>();
1210            assert!(events.iter().any(|event| matches!(event, ChartEvent::CrosshairMoved(Some(point)) if point.target == Some(PointerTarget::Element(ChartElementId::Indicator(other))))), "{events:?}");
1211            // The pane below the merged pair is the second pane, not the third study, and a pane
1212            // of bar series counts as one, its pointer snapping to their closes.
1213            let series = chart.add_series(crate::widget::SeriesOptions::default());
1214            chart.set_data(series, bars.clone());
1215            let series_pane = chart.add_pane();
1216            assert!(chart.move_series_to_pane(series, series_pane));
1217            let below = chart.add_indicator(Box::new(crate::studies::RSI::default()));
1218            frame(&ctx, &mut chart, 1.96, vec![Event::PointerGone]);
1219            chart.drain_events().for_each(drop);
1220            let map = chart.series_price_map(series).unwrap();
1221            let pos = Pos2::new(
1222                chart.get_chart_mapping().idx_to_x(70),
1223                map.price_to_y(bars[70].close) + 2.0,
1224            );
1225            assert!(chart.pane_layout.panes[2].rect.contains(pos));
1226            frame(&ctx, &mut chart, 1.965, vec![Event::PointerMoved(pos)]);
1227            let events = chart.drain_events().collect::<Vec<_>>();
1228            assert!(events.iter().any(|event| matches!(event, ChartEvent::CrosshairMoved(Some(point)) if point.series == series && point.pane == 2 && point.price == bars[70].close)), "{events:?}");
1229            let pane = chart.last_rendered_indicator_panes.last().unwrap();
1230            let pos = Pos2::new(pane.mapping.idx_to_x(70), pane.chart_rect.center().y);
1231            frame(&ctx, &mut chart, 1.97, vec![Event::PointerMoved(pos)]);
1232            let events = chart.drain_events().collect::<Vec<_>>();
1233            assert!(events.iter().any(|event| matches!(event, ChartEvent::CrosshairMoved(Some(point)) if point.series == below && point.pane == 3)), "{events:?}");
1234            // Panes count from the top as drawn, so the price pane moved to the bottom is fourth.
1235            assert!(chart.move_pane(super::super::PaneId::MAIN, usize::MAX));
1236            frame(&ctx, &mut chart, 1.98, vec![Event::PointerGone]);
1237            chart.drain_events().for_each(drop);
1238            let pos = chart.last_rendered_price_rect.center();
1239            frame(&ctx, &mut chart, 1.99, vec![Event::PointerMoved(pos)]);
1240            let events = chart.drain_events().collect::<Vec<_>>();
1241            assert!(events.iter().any(|event| matches!(event, ChartEvent::CrosshairMoved(Some(point)) if point.series == SeriesId::MAIN && point.pane == 3)), "{events:?}");
1242            chart.remove_series(series);
1243            chart.remove_series(below);
1244            chart.remove_series(other);
1245            chart.remove_series(id);
1246            chart.set_crosshair_mode(crate::config::CrosshairMode::Normal);
1247            frame(&ctx, &mut chart, 2.0, vec![Event::PointerGone]);
1248            chart.drain_events().for_each(drop);
1249        }
1250        chart.config.show_volume = true;
1251        frame(&ctx, &mut chart, 2.1, vec![]);
1252        chart.drain_events().for_each(drop);
1253        let volume = chart.last_rendered_volume_rect;
1254        let pos = Pos2::new(chart.get_chart_mapping().idx_to_x(70), volume.center().y);
1255        for (step, (mode, price)) in [
1256            (crate::config::CrosshairMode::Normal, 0.5),
1257            (crate::config::CrosshairMode::Magnet, 1.0),
1258        ]
1259        .into_iter()
1260        .enumerate()
1261        {
1262            chart.set_crosshair_mode(mode);
1263            frame(
1264                &ctx,
1265                &mut chart,
1266                2.2 + step as f64,
1267                vec![Event::PointerMoved(pos)],
1268            );
1269            chart.drain_events().for_each(drop);
1270            for pressed in [true, false] {
1271                frame(
1272                    &ctx,
1273                    &mut chart,
1274                    2.3 + step as f64,
1275                    vec![Event::PointerButton {
1276                        pos,
1277                        button: PointerButton::Secondary,
1278                        pressed,
1279                        modifiers: Default::default(),
1280                    }],
1281                );
1282            }
1283            assert!(chart.drain_events().any(|event| matches!(event, ChartEvent::Click { position, button: PointerButton::Secondary } if position.series == SeriesId::VOLUME && (position.price - price).abs() < 0.001)));
1284        }
1285        frame(&ctx, &mut chart, 3.5, vec![Event::PointerGone]);
1286        chart.drain_events().for_each(drop);
1287        // Added series, drawings, touch input and held modifiers reach the payload.
1288        let series = chart.add_series(Default::default());
1289        chart.set_data(series, bars.clone());
1290        assert!(chart.set_series_visible(series, false));
1291        #[cfg(feature = "drawings")]
1292        {
1293            let mut line = crate::drawings::Drawing::with_color(
1294                1,
1295                crate::drawings::DrawingToolType::HorizontalLine,
1296                [255, 0, 0, 255],
1297            );
1298            line.chart_points = vec![crate::drawings::ChartPoint::new(50.0, 150.0)];
1299            chart.drawings_mut().drawings.push(line);
1300        }
1301        frame(&ctx, &mut chart, 3.6, vec![]);
1302        chart.drain_events().for_each(drop);
1303        let map = chart.get_chart_mapping();
1304        let pos = Pos2::new(map.idx_to_x(70), map.price_to_y(150.0));
1305        frame(
1306            &ctx,
1307            &mut chart,
1308            3.7,
1309            vec![
1310                Event::ModifiersChanged(Modifiers::SHIFT),
1311                Event::Touch {
1312                    device_id: egui::TouchDeviceId(0),
1313                    id: egui::TouchId(0),
1314                    phase: egui::TouchPhase::Start,
1315                    pos,
1316                    force: None,
1317                },
1318                Event::PointerMoved(pos),
1319            ],
1320        );
1321        let events = chart.drain_events().collect::<Vec<_>>();
1322        let Some(ChartEvent::CrosshairMoved(Some(pointer))) = events.last() else {
1323            panic!("{events:?}")
1324        };
1325        assert_eq!(pointer.series_bars, vec![(series, bars[70].clone())]);
1326        #[cfg(feature = "drawings")]
1327        assert_eq!(pointer.target, Some(PointerTarget::Drawing(1)));
1328        assert_eq!(
1329            (pointer.modifiers, pointer.kind),
1330            (Modifiers::SHIFT, PointerKind::Touch)
1331        );
1332        for kind in [PointerKind::Pen, PointerKind::Mouse] {
1333            chart.set_pointer_kind(Some(kind));
1334            frame(&ctx, &mut chart, 3.75, vec![Event::PointerMoved(pos)]);
1335            assert!(chart.drain_events().any(|event| matches!(event,
1336                ChartEvent::CrosshairMoved(Some(pointer)) if pointer.kind == kind
1337                    && pointer.series_bars == vec![(series, bars[70].clone())]
1338            )));
1339        }
1340        chart.set_pointer_kind(None);
1341        let mut empty = bars.clone();
1342        empty[70] = Bar::whitespace(bars[70].time);
1343        empty[70].payload = Some(std::sync::Arc::new(serde_json::json!({ "id": "empty" })));
1344        chart.set_data(SeriesId::MAIN, empty.clone());
1345        chart.set_data(series, empty.clone());
1346        frame(&ctx, &mut chart, 3.76, vec![Event::PointerMoved(pos)]);
1347        let pointer = chart
1348            .drain_events()
1349            .find_map(|event| match event {
1350                ChartEvent::CrosshairMoved(Some(pointer)) => Some(pointer),
1351                _ => None,
1352            })
1353            .unwrap();
1354        assert!(pointer.bar.as_ref().unwrap().is_whitespace());
1355        assert!(pointer.series_bars[0].1.is_whitespace());
1356        assert_eq!(pointer.series_bars[0].1.payload, empty[70].payload);
1357        assert_eq!(pointer, pointer.clone());
1358        frame(&ctx, &mut chart, 3.77, vec![]);
1359        assert!(
1360            !chart
1361                .drain_events()
1362                .any(|event| matches!(event, ChartEvent::CrosshairMoved(_)))
1363        );
1364        empty[70].payload = Some(std::sync::Arc::new(serde_json::json!({ "id": "changed" })));
1365        chart.set_data(series, empty);
1366        frame(&ctx, &mut chart, 3.78, vec![]);
1367        assert!(chart.drain_events().any(|event| matches!(event,
1368            ChartEvent::CrosshairMoved(Some(updated)) if updated != pointer
1369        )));
1370        chart.set_data(SeriesId::MAIN, bars.clone());
1371        chart.remove_series(series);
1372        #[cfg(feature = "drawings")]
1373        chart.drawings_mut().drawings.clear();
1374        frame(
1375            &ctx,
1376            &mut chart,
1377            3.8,
1378            vec![Event::ModifiersChanged(Modifiers::NONE), Event::PointerGone],
1379        );
1380        chart.drain_events().for_each(drop);
1381        // The second of two quick clicks follows its click with a double click at the same point.
1382        for (step, pressed) in [true, false, true, false].into_iter().enumerate() {
1383            frame(
1384                &ctx,
1385                &mut chart,
1386                3.95 + step as f64 * 0.01,
1387                vec![
1388                    Event::PointerMoved(pos),
1389                    Event::PointerButton {
1390                        pos,
1391                        button: PointerButton::Primary,
1392                        pressed,
1393                        modifiers: Modifiers::NONE,
1394                    },
1395                ],
1396            );
1397        }
1398        let events = chart.drain_events().collect::<Vec<_>>();
1399        let [
1400            ..,
1401            ChartEvent::Click { position, .. },
1402            ChartEvent::DoubleClick { position: second },
1403        ] = events.as_slice()
1404        else {
1405            panic!("{events:?}")
1406        };
1407        assert_eq!((position.index, second), (70, position));
1408        let doubles = events
1409            .iter()
1410            .filter(|event| matches!(event, ChartEvent::DoubleClick { .. }));
1411        assert_eq!(doubles.count(), 1);
1412        frame(&ctx, &mut chart, 3.99, vec![Event::PointerGone]);
1413        chart.drain_events().for_each(drop);
1414        let range = chart.visible_time_range().unwrap();
1415        chart.apply_synced_time_range(VisibleTimeRange {
1416            from: range.from + chrono::Duration::days(1),
1417            to: range.to + chrono::Duration::days(1),
1418        });
1419        frame(&ctx, &mut chart, 4.0, vec![]);
1420        assert!(
1421            chart
1422                .drain_events()
1423                .any(|event| matches!(event, ChartEvent::VisibleRangeChanged(Some(_))))
1424        );
1425        chart.set_data(SeriesId::MAIN, vec![]);
1426        frame(&ctx, &mut chart, 4.1, vec![]);
1427        assert_eq!(
1428            chart.drain_events().collect::<Vec<_>>(),
1429            vec![
1430                ChartEvent::DataLoaded {
1431                    series: SeriesId::MAIN,
1432                    bars: 0
1433                },
1434                ChartEvent::VisibleRangeChanged(None),
1435                ChartEvent::VisibleLogicalRangeChanged(None),
1436            ]
1437        );
1438    }
1439}