Skip to main content

egui_charts/widget/
selection.rs

1//! Selection API, main-chart hit testing and selection handles.
2
3use super::{Chart, RightClickTarget};
4use crate::chart::indicators::{
5    SelectionDotConfig, hit_test_indicator, render_indicator_selection_dots,
6};
7use crate::chart::rendering::ChartMapping;
8use crate::chart::selection::{ChartElementId, SeriesId};
9use crate::chart::series::{
10    SelectionHandleConfig, calculate_dot_interval, hit_test_candles, hit_test_volume,
11    render_candle_selection_dots,
12};
13use crate::studies::IndicatorRegistry;
14use egui::Pos2;
15
16impl Chart {
17    // =========================================================================
18    // Selection API
19    // =========================================================================
20
21    /// Returns the chart element the user has currently selected, if any.
22    ///
23    /// Selection is driven by clicking a series, an overlay indicator line, or a
24    /// separate-pane indicator line. Host apps read this to open a settings
25    /// dialog for, or delete, the selected object. Returns `None` when nothing
26    /// is selected (for example after a click on empty chart area).
27    pub fn selected_element(&self) -> Option<ChartElementId> {
28        self.selection.selected_id()
29    }
30
31    /// Returns the index at which the current selection was made, if any.
32    /// On price-based types it counts elements, otherwise source bars.
33    ///
34    /// This is the data-space index of the segment that was clicked, useful for
35    /// anchoring tooltips or context menus near the click.
36    pub fn selected_bar(&self) -> Option<usize> {
37        self.selection.selected_bar()
38    }
39
40    /// Clears any current selection.
41    ///
42    /// Equivalent to clicking empty chart area. Call this after the host app has
43    /// finished acting on a selection (e.g. closing a settings dialog).
44    pub fn clear_selection(&mut self) {
45        self.selection.deselect();
46    }
47
48    /// Drains the right-click target captured during the last frame, if any.
49    ///
50    /// Returns `Some` exactly once per right-click: the chart hit-tests the
51    /// cursor against drawings, indicators, and series (same priority as
52    /// left-click selection) and records where the click landed. The hit object
53    /// is also selected, so selection handles and any context menu stay
54    /// consistent.
55    ///
56    /// Hosts that build their own context menus read this each frame and open
57    /// the appropriate menu at [`RightClickTarget`]`::pos`. With the `ui`
58    /// feature, `ui::context_menu::ChartMenus` consumes it for you.
59    ///
60    /// Carries no `ui`-feature types, so it is available without the `ui`
61    /// feature.
62    pub fn take_right_click(&mut self) -> Option<RightClickTarget> {
63        self.right_click.take()
64    }
65
66    /// Draws selection handles for the element currently selected on the main
67    /// chart (an overlay indicator line or a price/volume series).
68    ///
69    /// Handles of a study in a pane are drawn with its pane, which lies outside
70    /// the main chart rect.
71    pub(super) fn render_main_chart_selection(
72        &self,
73        painter: &egui::Painter,
74        coords: &ChartMapping,
75        visible_data: &[crate::model::Bar],
76        visible_range: std::ops::Range<usize>,
77    ) {
78        let Some(selected) = self.selection.selected_id() else {
79            return;
80        };
81
82        match selected {
83            ChartElementId::Indicator(id) => {
84                let Some(indicator) = self
85                    .study_ids
86                    .iter()
87                    .position(|study| *study == id)
88                    .and_then(|at| self.rendered_studies().indicators().get(at))
89                    .map(|study| study.as_ref())
90                else {
91                    return;
92                };
93                if !indicator.is_visible()
94                    || !indicator.is_overlay()
95                    || (indicator.is_volume() && !self.shows_volume())
96                {
97                    return;
98                }
99                let volume_coords = (indicator.is_volume()
100                    && !matches!(indicator.scale(), crate::studies::StudyScale::Shared(_)))
101                .then(|| {
102                    super::volume::mapping(
103                        coords,
104                        super::volume::overlay_rect(coords.rect, &self.config),
105                        indicator,
106                        &visible_range,
107                        Some(&self.merge_groups),
108                    )
109                });
110                let study_coords = crate::chart::rendering::study_mapping(indicator, coords);
111                let coords = volume_coords.as_ref().unwrap_or(&study_coords);
112                let config = SelectionDotConfig {
113                    dot_interval: calculate_dot_interval(coords.bar_spacing),
114                    ..Default::default()
115                };
116                // Multi-line indicators highlight every line so the whole study
117                // reads as selected.
118                for line_idx in 0..indicator.line_count() {
119                    render_indicator_selection_dots(
120                        painter,
121                        indicator,
122                        line_idx,
123                        visible_range.clone(),
124                        coords,
125                        |price| coords.price_to_y(price),
126                        &config,
127                    );
128                }
129            }
130            ChartElementId::Series(series_id) => {
131                let config = SelectionHandleConfig {
132                    dot_interval: calculate_dot_interval(coords.bar_spacing),
133                    ..Default::default()
134                };
135                let values: Vec<f64> = visible_data
136                    .iter()
137                    .map(|b| {
138                        if series_id == SeriesId::VOLUME {
139                            b.volume
140                        } else {
141                            b.close
142                        }
143                    })
144                    .collect();
145                match series_id {
146                    SeriesId::VOLUME => {
147                        // Volume handles sit on the volume rect; render on the
148                        // bar tops at the configured interval.
149                        let volume_coords = coords.with_rect(self.last_rendered_volume_rect);
150                        let max_volume = visible_data
151                            .iter()
152                            .map(|b| b.volume)
153                            .fold(0.0_f64, f64::max);
154                        if max_volume <= 0.0 {
155                            return;
156                        }
157                        render_candle_selection_dots(
158                            painter,
159                            visible_range,
160                            &volume_coords,
161                            &values,
162                            |volume| {
163                                let norm = (volume / max_volume) as f32;
164                                volume_coords.rect.bottom() - norm * volume_coords.rect.height()
165                            },
166                            &config,
167                        );
168                    }
169                    _ => {
170                        render_candle_selection_dots(
171                            painter,
172                            visible_range,
173                            coords,
174                            &values,
175                            |price| coords.price_to_y(price),
176                            &config,
177                        );
178                    }
179                }
180            }
181        }
182    }
183}
184
185/// Hit-test a click on the main chart area, applying selection priority.
186///
187/// Overlay indicator lines take priority over the series beneath them, matching
188/// the visual stacking order in `order`, from back to front. `study_ids` holds the id
189/// of each study in `studies`.
190/// Returns the hit element and the bar index where the hit occurred, or `None`
191/// when the click landed on empty chart area.
192pub(super) fn hit_test_main_chart(
193    click_pos: Pos2,
194    studies: &IndicatorRegistry,
195    study_ids: &[SeriesId],
196    order: &[SeriesId],
197    coords: &ChartMapping,
198    volume_coords: Option<&ChartMapping>,
199    bars: &[crate::model::Bar],
200    max_volume: f64,
201    visible_range: std::ops::Range<usize>,
202    hit_tolerance: f32,
203    groups: Option<&super::merge::Groups>,
204) -> Option<(ChartElementId, usize)> {
205    if bars.is_empty() {
206        return None;
207    }
208
209    // Only bars within reach of the click can be hit, so a zoomed-out chart tests a few of them.
210    let reach = hit_tolerance.max(crate::chart::hit_test::HIT_TOLERANCE) + coords.bar_spacing;
211    let near = visible_range
212        .start
213        .max(coords.x_to_idx(click_pos.x - reach).saturating_sub(1))
214        ..visible_range
215            .end
216            .min(coords.x_to_idx(click_pos.x + reach).saturating_add(2));
217
218    let main = order
219        .iter()
220        .position(|id| *id == SeriesId::MAIN)
221        .unwrap_or(0);
222    let overlay_hit = |under_series: bool| {
223        for (at, id) in order.iter().enumerate().rev() {
224            if (at < main) != under_series {
225                continue;
226            }
227            let Some(idx) = study_ids.iter().position(|study| study == id) else {
228                continue;
229            };
230            let indicator = &studies.indicators()[idx];
231            let volume_mapping = if indicator.is_volume()
232                && !matches!(indicator.scale(), crate::studies::StudyScale::Shared(_))
233            {
234                if !indicator.is_overlay() || !indicator.is_visible() {
235                    continue;
236                }
237                let Some(volume) = volume_coords else {
238                    continue;
239                };
240                Some(super::volume::mapping(
241                    coords,
242                    volume.rect,
243                    indicator.as_ref(),
244                    &visible_range,
245                    groups,
246                ))
247            } else {
248                None
249            };
250            let study_coords = crate::chart::rendering::study_mapping(indicator.as_ref(), coords);
251            let coords = volume_mapping.as_ref().unwrap_or(&study_coords);
252            if let Some(hit) = hit_test_indicator(
253                click_pos,
254                indicator.as_ref(),
255                idx,
256                visible_range.clone(),
257                coords,
258                |price| coords.price_to_y(price),
259            ) {
260                return Some((
261                    ChartElementId::Indicator(study_ids[hit.indicator_idx]),
262                    hit.bar_idx,
263                ));
264            }
265        }
266        None
267    };
268
269    // 1. Overlay indicators drawn over the series.
270    if let Some(hit) = overlay_hit(false) {
271        return Some(hit);
272    }
273
274    // 2. Volume is painted over the price series.
275    if order.contains(&SeriesId::MAIN)
276        && !studies
277            .indicators()
278            .iter()
279            .any(|study| study.is_volume() && study.is_overlay())
280        && let Some(volume_coords) = volume_coords
281        && let Some(hit) = hit_test_volume(click_pos, bars, near.clone(), volume_coords, max_volume)
282    {
283        return Some((ChartElementId::Series(hit.series_id), hit.bar_idx));
284    }
285
286    // 3. Main price series (candles/bars/line).
287    if order.contains(&SeriesId::MAIN)
288        && let Some(hit) = hit_test_candles(
289            click_pos,
290            bars,
291            near,
292            coords,
293            |price| coords.price_to_y(price),
294            &crate::chart::series::HitTestConfig {
295                line_tolerance: hit_tolerance,
296                ..Default::default()
297            },
298        )
299    {
300        return Some((ChartElementId::Series(hit.series_id), hit.bar_idx));
301    }
302
303    // 4. Overlay indicators drawn under the series.
304    overlay_hit(true)
305}
306
307#[cfg(test)]
308mod selection_tests {
309    use super::*;
310    use crate::model::Bar;
311    use crate::scales::{PriceMap, PriceRange, PriceTransform};
312    use crate::studies::{CustomIndicator, IndicatorValue};
313    use chrono::{TimeZone, Utc};
314    use egui::{Pos2, Rect, Vec2};
315
316    /// Build a deterministic mapping plus a small candle series.
317    ///
318    /// The series is flat OHLC at the integer prices `[10, 11, 12, 13, 14]` so
319    /// every candle has a visible body and an exact price-to-screen mapping.
320    fn fixture() -> (ChartMapping, Vec<Bar>) {
321        let rect = Rect::from_min_size(Pos2::new(0.0, 0.0), Vec2::new(500.0, 400.0));
322        let bars: Vec<Bar> = (0..5)
323            .map(|i| {
324                let close = 10.0 + i as f64;
325                Bar::new(
326                    Utc.timestamp_opt(i as i64, 0).unwrap(),
327                    close - 0.4,
328                    close + 0.5,
329                    close - 0.5,
330                    close + 0.4,
331                    100.0,
332                )
333            })
334            .collect();
335        // base_idx = last bar, no scroll offset, price window covers the data.
336        let range = PriceRange::new(8.0, 16.0);
337        let price = PriceMap::new(
338            PriceTransform::default(),
339            range,
340            rect.min.y,
341            rect.max.y,
342            false,
343        );
344        let mapping = ChartMapping::new(rect, 40.0, 0, bars.len() - 1, 0.0, price);
345        (mapping, bars)
346    }
347
348    fn overlay_at(values: Vec<IndicatorValue>) -> IndicatorRegistry {
349        let mut registry = IndicatorRegistry::new();
350        let captured = values.clone();
351        registry.add(Box::new(
352            CustomIndicator::new("TestLine", move |_| captured.clone()).with_overlay(true),
353        ));
354        // Prime the cached values without needing real bar input.
355        registry.calculate_all(&[]);
356        registry
357    }
358
359    #[test]
360    fn click_on_candle_or_near_its_wick_selects_main_series() {
361        let (mapping, bars) = fixture();
362        // The center of bar 2's body (close = 12.4, open = 11.6), and its upper wick at 12.45,
363        // 8 points from the bar's centre: outside the default 5 point tolerance, inside 10.
364        let bar = &bars[2];
365        let mid_price = (bar.open + bar.close) / 2.0;
366        let at = |dx, price| Pos2::new(mapping.idx_to_x(2) + dx, mapping.price_to_y(price));
367        let tolerance = crate::chart::hit_test::HIT_TOLERANCE;
368        let selected = Some((ChartElementId::Series(SeriesId::MAIN), 2));
369        for (bars, pos, tolerance, expected) in [
370            (bars.as_slice(), at(0.0, mid_price), tolerance, selected),
371            (bars.as_slice(), at(8.0, 12.45), tolerance, None),
372            (bars.as_slice(), at(8.0, 12.45), 10.0, selected),
373            (
374                bars.as_slice(),
375                Pos2::new(mapping.idx_to_x(2), mapping.rect.min.y + 1.0),
376                tolerance,
377                None,
378            ),
379            (&[][..], mapping.rect.center(), tolerance, None),
380        ] {
381            let hit = hit_test_main_chart(
382                pos,
383                &IndicatorRegistry::new(),
384                &[],
385                &[SeriesId::MAIN],
386                &mapping,
387                None,
388                bars,
389                100.0,
390                0..bars.len(),
391                tolerance,
392                None,
393            );
394            assert_eq!(hit, expected, "{pos:?}, {tolerance}, {} bars", bars.len());
395        }
396    }
397
398    #[test]
399    fn selection_follows_overlay_volume_and_price_paint_order() {
400        let (mapping, bars) = fixture();
401        // Place an indicator value at bar 2 and bar 3 exactly on the candle's
402        // close price so its line passes through the body. A click there must
403        // resolve to the overlay, not the series beneath it.
404        let mut values = vec![IndicatorValue::None; bars.len()];
405        values[2] = IndicatorValue::Single(bars[2].close);
406        values[3] = IndicatorValue::Single(bars[3].close);
407        let registry = overlay_at(values);
408
409        let pos = Pos2::new(mapping.idx_to_x(2), mapping.price_to_y(bars[2].close));
410        let volume = mapping.with_rect(egui::Rect::from_min_max(
411            Pos2::new(mapping.rect.left(), pos.y - 10.0),
412            Pos2::new(mapping.rect.right(), pos.y + 10.0),
413        ));
414        let empty = IndicatorRegistry::new();
415        for (studies, order, volume, expected) in [
416            (
417                &registry,
418                &[SeriesId::MAIN, SeriesId(2)][..],
419                Some(&volume),
420                ChartElementId::Indicator(SeriesId(2)),
421            ),
422            (
423                &empty,
424                &[SeriesId::MAIN],
425                Some(&volume),
426                ChartElementId::Series(SeriesId::VOLUME),
427            ),
428            (
429                &empty,
430                &[SeriesId::MAIN][..],
431                None,
432                ChartElementId::Series(SeriesId::MAIN),
433            ),
434            // Sent behind the series, the overlay yields to it.
435            (
436                &registry,
437                &[SeriesId(2), SeriesId::MAIN],
438                None,
439                ChartElementId::Series(SeriesId::MAIN),
440            ),
441        ] {
442            let hit = hit_test_main_chart(
443                pos,
444                studies,
445                &[SeriesId(2)],
446                order,
447                &mapping,
448                volume,
449                &bars,
450                100.0,
451                0..bars.len(),
452                crate::chart::hit_test::HIT_TOLERANCE,
453                None,
454            );
455            assert_eq!(hit, Some((expected, 2)));
456        }
457    }
458
459    #[test]
460    fn pane_indicator_does_not_steal_overlay_priority() {
461        let (mapping, bars) = fixture();
462        // A non-overlay (pane) indicator must be ignored by the main-chart hit
463        // test even though its values sit on the candle body.
464        let mut values = vec![IndicatorValue::None; bars.len()];
465        values[2] = IndicatorValue::Single(bars[2].close);
466        let mut registry = IndicatorRegistry::new();
467        let captured = values.clone();
468        registry.add(Box::new(
469            CustomIndicator::new("Pane", move |_| captured.clone()).with_overlay(false),
470        ));
471        registry.calculate_all(&[]);
472
473        let bar = &bars[2];
474        let mid_price = (bar.open + bar.close) / 2.0;
475        let pos = Pos2::new(mapping.idx_to_x(2), mapping.price_to_y(mid_price));
476        let hit = hit_test_main_chart(
477            pos,
478            &registry,
479            &[SeriesId(2)],
480            &[SeriesId::MAIN, SeriesId(2)],
481            &mapping,
482            None,
483            &bars,
484            100.0,
485            0..bars.len(),
486            crate::chart::hit_test::HIT_TOLERANCE,
487            None,
488        );
489        // Falls through to the series, since the pane indicator is not an overlay.
490        assert_eq!(hit, Some((ChartElementId::Series(SeriesId::MAIN), 2)));
491    }
492
493    /// Guards stable study ids: with ids taken from positions, removing a study moved the
494    /// selection to the study after it, and removing the selected study must clear the
495    /// selection instead of leaving it on a study that is gone.
496    #[test]
497    fn removing_a_study_keeps_the_selection_on_its_id() {
498        use crate::model::BarData;
499
500        let mut chart = Chart::new(BarData::default());
501        let ids: Vec<SeriesId> = ["A", "B", "C"]
502            .into_iter()
503            .map(|name| {
504                let study =
505                    CustomIndicator::new(name, |bars| vec![IndicatorValue::None; bars.len()]);
506                chart.add_indicator(Box::new(study.with_overlay(false)))
507            })
508            .collect();
509        chart
510            .selection
511            .select(ChartElementId::Indicator(ids[2]), None);
512        assert!(chart.remove_series(ids[0]));
513        assert_eq!(
514            chart.selected_element(),
515            Some(ChartElementId::Indicator(ids[2]))
516        );
517        assert!(chart.remove_series(ids[2]));
518        assert_eq!(chart.selected_element(), None);
519    }
520}