Skip to main content

egui_charts/widget/
mod.rs

1//! The primary Chart widget for embedding interactive financial charts in egui.
2//!
3//! This module provides [`Chart`], the main entry point for rendering OHLCV
4//! (Open-High-Low-Close-Volume) financial data as an interactive egui widget.
5//! It supports multiple chart types (candlestick, line, area, bar, Renko, Kagi),
6//! real-time data streaming, drawing tools, technical indicators, and
7//! multi-chart synchronization.
8//!
9//! # Quick Start
10//!
11//! ```rust,ignore
12//! use egui_charts::widget::Chart;
13//! use egui_charts::model::BarData;
14//!
15//! // Create chart with OHLCV data
16//! let mut chart = Chart::new(bar_data);
17//!
18//! // Show the chart in your egui update loop
19//! egui::CentralPanel::default().show(ctx, |ui| {
20//!     chart.show(ui);
21//! });
22//! ```
23//!
24//! # Builder Pattern
25//!
26//! Configure the chart before showing it:
27//!
28//! ```rust,ignore
29//! let mut chart = Chart::new(data)
30//!     .visible_bars(100)          // Show 100 bars at a time
31//!     .config(my_chart_config)    // Custom visual config
32//!     .with_chart_options(opts);  // Scroll/zoom behavior
33//! ```
34//!
35//! # Real-Time Updates
36//!
37//! For live data feeds, reuse the chart instance across frames:
38//!
39//! ```rust,ignore
40//! // In your app struct:
41//! struct MyApp {
42//!     chart: Chart,
43//! }
44//!
45//! // On new data:
46//! let auto_scrolled = self.chart.update_data(new_bar_data);
47//! self.chart.show(ui);
48//! ```
49//!
50//! # Drawing Tools and Indicators
51//!
52//! ```rust,ignore
53//! // With drawing tools and indicators:
54//! chart.show_with_indicators(
55//!     ui,
56//!     Some(&mut drawing_manager),
57//!     Some(&indicator_registry),
58//! );
59//! ```
60//!
61//! # Multi-Chart Sync
62//!
63//! Synchronize crosshairs and time scales between multiple charts:
64//!
65//! ```rust,ignore
66//! // Emit crosshair position from chart A
67//! let hover_idx = chart_a.get_hover_bar_idx();
68//!
69//! // Apply to chart B
70//! chart_b.set_synced_crosshair_bar_idx(hover_idx);
71//!
72//! // Sync time scales
73//! let (spacing, offset) = chart_a.get_time_scale_state();
74//! chart_b.apply_synced_time_scale(spacing, offset);
75//! ```
76//!
77//! # Sub-modules
78//!
79//! - [`builder`] -- Constructor and configuration methods for [`Chart`]
80//! - [`indicator_pane`] -- Separate indicator panels (RSI, MACD, Stochastic)
81
82use crate::chart::cursor_modes::CursorModeState;
83use crate::chart::indicators::{
84    SelectionDotConfig, hit_test_indicator, hit_test_pane_indicator,
85    render_indicator_selection_dots,
86};
87use crate::chart::renderers::{self, ChartMapping, PriceScale, RenderContext, StyleColors};
88use crate::chart::selection::{ChartElementId, SelectionState, SeriesId};
89use crate::chart::series::{
90    SelectionHandleConfig, SeriesSettings, calculate_dot_interval, hit_test_candles,
91    hit_test_volume, render_candle_selection_dots,
92};
93use crate::config::{BackgroundStyle, ChartConfig, ChartOptions, WatermarkPos};
94use crate::drawings::DrawingManager;
95use crate::model::ChartState;
96use crate::model::ChartType;
97use crate::scales::TimeFormatterBuilder;
98use crate::studies::IndicatorRegistry;
99use crate::validation::DataValidator;
100pub mod indicator_pane;
101use crate::styles::{sizing, typography};
102use crate::tokens::DESIGN_TOKENS;
103use egui::{Pos2, Rect, Response, Sense, Ui, Vec2};
104pub use indicator_pane::{
105    IndicatorCoordParams, IndicatorPane, IndicatorPaneConfig, PaneInteraction,
106};
107
108// Re-export from logic layer
109use crate::chart::{helpers, rendering, state};
110
111pub mod builder;
112
113pub use helpers::{apply_price_zoom, y_to_price};
114pub use state::{BoxZoomMode, BoxZoomState, ElasticBounceState, KineticScrollState};
115
116/// Interactive financial chart widget for egui.
117///
118/// `Chart` is the core rendering and interaction engine for displaying OHLCV
119/// financial data. It handles all aspects of chart visualization including
120/// candlestick/bar/line rendering, pan/zoom interactions, crosshair display,
121/// drawing tool integration, and indicator overlays.
122///
123/// # Creating a Chart
124///
125/// Use [`Chart::new`] or [`Chart::with_config`] to construct, then call
126/// [`Chart::show`] each frame to render:
127///
128/// ```rust,ignore
129/// use egui_charts::widget::Chart;
130/// use egui_charts::model::BarData;
131///
132/// let mut chart = Chart::new(bar_data);
133///
134/// egui::CentralPanel::default().show(ctx, |ui| {
135///     let response = chart.show(ui);
136///     // response can be used for additional interaction handling
137/// });
138/// ```
139///
140/// # Supported Chart Types
141///
142/// - **Candles** -- Standard Japanese candlestick chart
143/// - **Bars** -- OHLC bar chart
144/// - **Line** -- Close-price line chart
145/// - **Area** -- Filled area under the close-price line
146/// - **Renko** -- Fixed-size brick chart (set brick size with [`Chart::set_renko_brick_size`])
147/// - **Kagi** -- Reversal-based chart (set reversal amount with [`Chart::set_kagi_reversal_amount`])
148///
149/// # Interaction Features
150///
151/// - **Pan**: Click and drag to scroll through history (with kinetic scrolling)
152/// - **Zoom**: Mouse wheel to zoom in/out, pinch-to-zoom on trackpads
153/// - **Box zoom**: Drag-select a region to zoom into (when zoom mode is active)
154/// - **Price scale drag**: Drag the price axis to scale vertically
155/// - **Crosshair**: Hover to see price/time at cursor position
156/// - **Keyboard shortcuts**: Arrow keys, Home/End, +/- for navigation
157/// - **Double-click**: Reset zoom on price or time axis
158///
159/// # Architecture
160///
161/// `Chart` owns a [`ChartState`] (data + coordinate systems) and a
162/// [`ChartConfig`] (visual styling). Rendering is delegated to specialized
163/// modules in `crate::chart::rendering`, while interaction logic lives in
164/// `crate::chart::state`.
165pub struct Chart {
166    /// Backend state holding OHLCV data and coordinate system (time scale, price range).
167    pub state: ChartState,
168    /// Visual configuration controlling colors, padding, grid visibility, and more.
169    pub config: ChartConfig,
170    /// Chart behavior options (bar spacing, scroll/zoom constraints, time scale settings).
171    pub chart_options: ChartOptions,
172    /// Starting index of visible range (for backward compatibility)
173    pub(crate) start_idx: usize,
174    /// Desired number of visible bars (from app state)
175    pub(crate) desired_visible_bars: Option<usize>,
176    /// Cache of last computed visible bars for external syncing
177    pub(crate) last_visible_bars: usize,
178    /// Whether to apply `desired_visible_bars` on next frame only
179    pub(crate) apply_visible_bars_once: bool,
180    /// Kinetic scroll animation state (UI state only)
181    pub(crate) kinetic_scroll: KineticScrollState,
182    /// Last scroll position for drag tracking (UI state only)
183    pub(crate) scroll_start_pos: Option<Pos2>,
184    /// Initial right offset when starting scroll (for drag) (UI state only)
185    pub(crate) scroll_start_offset: Option<f32>,
186    /// Previous widget width for resize handling (UI state only)
187    pub(crate) prev_width: Option<f32>,
188    /// Drag state for price-axis scaling (UI state only)
189    pub(crate) price_scale_drag_start: Option<Pos2>,
190    /// Apply external start-index once to time scale (UI state only)
191    pub(crate) pending_start_idx: Option<usize>,
192    /// Chart type (candlestick, line, area, bar, Renko, Kagi)
193    pub(crate) chart_type: ChartType,
194    /// Renko brick size (for Renko charts)
195    pub(crate) renko_brick_size: f64,
196    /// Kagi reversal amount (for Kagi charts)
197    pub(crate) kagi_reversal_amount: f64,
198    /// Whether tracking mode is currently active
199    pub(crate) tracking_mode_active: bool,
200    /// Mouse entered chart area (for tracking mode exit detection)
201    pub(crate) mouse_in_chart: bool,
202    /// Data validator for detecting data mismatches
203    pub(crate) validator: Option<DataValidator>,
204    /// Right-click box zoom state
205    pub(crate) box_zoom: BoxZoomState,
206    /// Whether zoom mode is currently active (controlled by zoom toolbar button)
207    pub(crate) zoom_mode_active: bool,
208    /// Whether zoom was just applied in the last frame (for auto-deactivation)
209    pub(crate) zoom_just_applied: bool,
210    /// Current symbol being displayed (for legend)
211    pub(crate) symbol: String,
212    /// Current timeframe (for legend)
213    pub(crate) timeframe: String,
214    /// Cursor mode state (Demonstration, Magic, Eraser effects)
215    #[doc(hidden)]
216    pub cursor_modes: CursorModeState,
217    /// Last rendered price range (includes zoom adjustments) for external use
218    pub(crate) last_rendered_price_range: (f64, f64),
219    /// Last rendered price rect (actual rect used for candle rendering).
220    /// Use [`get_rendered_price_rect`](Chart::get_rendered_price_rect) instead.
221    #[doc(hidden)]
222    pub last_rendered_price_rect: Rect,
223    /// Last rendered volume rect (actual rect used for volume rendering)
224    pub(crate) last_rendered_volume_rect: Rect,
225    /// Last rendered indicator pane info for hit testing
226    /// Each entry: (indicator_index, panel_rect, chart_rect, y_min, y_max, coords)
227    pub(crate) last_rendered_indicator_panes: Vec<RenderedIndicatorPane>,
228    // =========================================================================
229    // Multi-Chart Sync State
230    // =========================================================================
231    /// External crosshair position from synced chart (bar index)
232    pub(crate) synced_crosshair_bar_idx: Option<f64>,
233    /// Last computed hover bar index (for sync emission to other charts)
234    pub(crate) last_hover_bar_idx: Option<f64>,
235
236    // =========================================================================
237    // Marks (Widget API)
238    // =========================================================================
239    /// Bar marks (annotations on chart bars, e.g., trade signals)
240    pub marks: Vec<crate::model::Marker>,
241    /// Timescale marks (annotations on the time axis)
242    pub timescale_marks: Vec<crate::model::Marker>,
243
244    // =========================================================================
245    // Selection State
246    // =========================================================================
247    /// Chart-wide click selection, shared by series, overlay indicators, and
248    /// pane indicators. Populated by click hit testing during rendering and
249    /// readable by host apps via [`Chart::selected_element`].
250    pub(crate) selection: SelectionState<ChartElementId>,
251    /// Right-click hit result captured during the last frame, drained by the
252    /// host via [`Chart::take_right_click`] (or by the turnkey context-menu
253    /// path in `TradingChart`). Holds no UI types so the core widget builds
254    /// without the `ui` feature.
255    pub(crate) right_click: Option<RightClickTarget>,
256    /// Registry index of a pane indicator whose legend close "x" was clicked
257    /// during the last frame, drained by the host via
258    /// [`Chart::take_indicator_remove`]. Holds no `ui`-feature types so the core
259    /// widget builds without the `ui` feature.
260    pub(crate) indicator_remove: Option<usize>,
261}
262
263/// The object a right-click landed on, with the data needed to position and
264/// populate a context menu.
265///
266/// Captured during [`Chart::show`]/[`Chart::show_with_indicators`] when the user
267/// right-clicks inside the chart area. Drain it once per frame with
268/// [`Chart::take_right_click`]. Right-clicking also selects the hit object (the
269/// same as a left click) so selection handles and the menu stay consistent, the
270/// way TradingView behaves.
271///
272/// This type intentionally carries no `ui`-feature types, so the core chart can
273/// surface right-clicks even when built with `--no-default-features`; the host
274/// (or the `ui`-gated turnkey menu in `TradingChart`) maps it to a concrete
275/// menu.
276#[derive(Clone, Copy, Debug, PartialEq)]
277pub enum RightClickTarget {
278    /// A series or overlay/pane indicator line was hit.
279    Element {
280        /// The selected chart element (series or indicator).
281        id: ChartElementId,
282        /// Data-space bar index under the cursor.
283        bar_idx: usize,
284        /// Screen position of the click (menu anchor).
285        pos: Pos2,
286        /// Price under the cursor at the click position.
287        price: f64,
288    },
289    /// A drawing object was hit. The drawing is identified by its
290    /// [`crate::drawings::DrawingManager`] id.
291    Drawing {
292        /// Id of the hit drawing within the drawing manager.
293        drawing_id: usize,
294        /// Screen position of the click (menu anchor).
295        pos: Pos2,
296    },
297    /// Empty chart area was hit (no series, indicator, or drawing).
298    Background {
299        /// Screen position of the click (menu anchor).
300        pos: Pos2,
301        /// Price under the cursor at the click position.
302        price: f64,
303    },
304}
305
306/// Information about a rendered indicator pane, used for hit testing and coordinate mapping.
307///
308/// After calling [`Chart::show_with_indicators`], each visible separate-pane indicator
309/// (RSI, MACD, etc.) produces a `RenderedIndicatorPane` entry stored in the chart.
310/// Platform code can use these to implement click-on-indicator-line selection,
311/// tooltip display, or other interactive features.
312///
313/// Retrieve with [`Chart::get_rendered_indicator_panes`].
314#[derive(Clone, Debug)]
315pub struct RenderedIndicatorPane {
316    /// Index of the indicator in the registry
317    pub indicator_idx: usize,
318    /// Full panel rect (including y-axis labels)
319    pub panel_rect: Rect,
320    /// Chart drawing area rect (excluding y-axis labels)
321    pub chart_rect: Rect,
322    /// Y-axis minimum value
323    pub y_min: f64,
324    /// Y-axis maximum value
325    pub y_max: f64,
326    /// Coordinate parameters for x-axis calculation
327    pub coords: IndicatorCoordParams,
328}
329
330/// Pre-computed layout rectangles for the chart's sub-regions.
331///
332/// Use [`Chart::calculate_layout_rects`] to obtain these rects for a given
333/// widget area. They are useful for external hit-testing (e.g., determining
334/// whether a click landed on the price area, volume area, or legend).
335#[derive(Clone, Copy, Debug)]
336pub struct ChartLayoutRects {
337    /// The overall widget rect (entire chart area including axes and padding).
338    pub widget_rect: Rect,
339    /// The main price/candle area where OHLC data is rendered.
340    pub price_rect: Rect,
341    /// The volume sub-area below the price chart (empty if volume is hidden).
342    pub volume_rect: Rect,
343    /// The legend/OHLC info area at the top of the chart.
344    pub legend_rect: Rect,
345}
346
347impl Default for ChartLayoutRects {
348    fn default() -> Self {
349        Self {
350            widget_rect: Rect::NOTHING,
351            price_rect: Rect::NOTHING,
352            volume_rect: Rect::NOTHING,
353            legend_rect: Rect::NOTHING,
354        }
355    }
356}
357
358impl Chart {
359    /// Calculate the layout sub-rects for a given widget rect.
360    ///
361    /// Given the overall widget area, this computes where the price chart,
362    /// volume bars, and legend/OHLC header will be drawn. Useful for
363    /// external hit-testing, overlay placement, or custom drawing on top
364    /// of specific chart regions.
365    ///
366    /// The layout respects current config flags like `show_ohlc_info`,
367    /// `show_time_labels`, and `show_volume`.
368    pub fn calculate_layout_rects(&self, widget_rect: Rect) -> ChartLayoutRects {
369        let bottom_padding = if self.config.show_time_labels {
370            30.0
371        } else {
372            20.0
373        };
374        let top_padding = if self.config.show_ohlc_info {
375            40.0
376        } else {
377            20.0
378        };
379        let right_padding = self.config.padding * 2.0;
380
381        // Legend rect is at the top of the widget
382        let legend_rect = if self.config.show_ohlc_info {
383            Rect::from_min_size(
384                widget_rect.min + Vec2::new(self.config.padding, 4.0),
385                Vec2::new(widget_rect.width() * 0.7, top_padding - 8.0),
386            )
387        } else {
388            Rect::NOTHING
389        };
390
391        let chart_rect = Rect::from_min_size(
392            widget_rect.min + Vec2::new(self.config.padding, top_padding),
393            Vec2::new(
394                widget_rect.width() - self.config.padding - right_padding,
395                widget_rect.height() - top_padding - bottom_padding,
396            ),
397        );
398
399        let (price_rect, volume_rect) = if self.config.show_volume {
400            let split_y =
401                chart_rect.min.y + chart_rect.height() * (1.0 - self.config.volume_height_fraction);
402            (
403                Rect::from_min_max(chart_rect.min, Pos2::new(chart_rect.max.x, split_y)),
404                Rect::from_min_max(Pos2::new(chart_rect.min.x, split_y), chart_rect.max),
405            )
406        } else {
407            (chart_rect, Rect::NOTHING)
408        };
409
410        ChartLayoutRects {
411            widget_rect,
412            price_rect,
413            volume_rect,
414            legend_rect,
415        }
416    }
417
418    /// Activates or deactivates box-zoom mode.
419    ///
420    /// When active, left-click drag draws a selection rectangle and zooms into
421    /// that region. The mode auto-deactivates after a successful zoom operation
422    /// (check with [`Chart::zoom_was_applied`]).
423    pub fn set_zoom_mode(&mut self, active: bool) {
424        self.zoom_mode_active = active;
425    }
426
427    /// Returns `true` if a box-zoom was completed in the most recent frame.
428    ///
429    /// Use this to auto-deactivate zoom mode in your toolbar after the user
430    /// completes a zoom selection.
431    pub fn zoom_was_applied(&self) -> bool {
432        self.zoom_just_applied
433    }
434
435    /// Sets the trading symbol displayed in the chart legend (e.g., "BTCUSD", "AAPL").
436    pub fn set_symbol(&mut self, symbol: &str) {
437        self.symbol = symbol.to_string();
438    }
439
440    /// Sets the timeframe label displayed in the chart legend (e.g., "1H", "1D", "1W").
441    pub fn set_timeframe_label(&mut self, timeframe: &str) {
442        self.timeframe = timeframe.to_string();
443    }
444
445    /// Sets the crosshair rendering style (Full, Dot, or Arrow).
446    ///
447    /// Use this to connect a toolbar cursor-type selector to the chart.
448    /// The style controls how the crosshair lines and labels are drawn
449    /// when the user hovers over the chart area.
450    pub fn set_crosshair_style(&mut self, style: crate::config::CrosshairStyle) {
451        self.chart_options.crosshair.style = style;
452    }
453
454    /// Apply series settings to chart colors and price source.
455    ///
456    /// Copies candlestick colors (bullish/bearish fill, border, wick) and the
457    /// price source field from the given [`SeriesSettings`] into the chart's
458    /// [`ChartConfig`]. Call this when the user changes series appearance in a
459    /// settings dialog.
460    pub fn apply_series_settings(&mut self, settings: &SeriesSettings) {
461        self.config.bullish_color = settings.bullish_color;
462        self.config.bearish_color = settings.bearish_color;
463        self.config.bullish_border_color = settings.bullish_border_color;
464        self.config.bearish_border_color = settings.bearish_border_color;
465        self.config.bullish_wick_color = settings.bullish_wick_color;
466        self.config.bearish_wick_color = settings.bearish_wick_color;
467        self.config.price_source = settings.price_source;
468    }
469
470    /// Draw the chart background (solid or gradient)
471    fn draw_background(&self, painter: &egui::Painter, rect: Rect) {
472        // Skip background when chart is inside a container that handles its own background
473        if self.config.skip_background {
474            return;
475        }
476
477        match self.config.background_style {
478            BackgroundStyle::Solid => {
479                painter.rect_filled(rect, 0.0, self.config.background_color);
480            }
481            BackgroundStyle::VerticalGradient {
482                top_color,
483                bottom_color,
484            } => {
485                // Draw vertical gradient using a mesh
486                let mesh = egui::Mesh {
487                    indices: vec![0, 1, 2, 2, 3, 0],
488                    vertices: vec![
489                        egui::epaint::Vertex {
490                            pos: rect.left_top(),
491                            uv: egui::epaint::WHITE_UV,
492                            color: top_color,
493                        },
494                        egui::epaint::Vertex {
495                            pos: rect.right_top(),
496                            uv: egui::epaint::WHITE_UV,
497                            color: top_color,
498                        },
499                        egui::epaint::Vertex {
500                            pos: rect.right_bottom(),
501                            uv: egui::epaint::WHITE_UV,
502                            color: bottom_color,
503                        },
504                        egui::epaint::Vertex {
505                            pos: rect.left_bottom(),
506                            uv: egui::epaint::WHITE_UV,
507                            color: bottom_color,
508                        },
509                    ],
510                    texture_id: egui::TextureId::default(),
511                };
512                painter.add(egui::Shape::mesh(mesh));
513            }
514            BackgroundStyle::HorizontalGradient {
515                left_color,
516                right_color,
517            } => {
518                // Draw horizontal gradient using a mesh
519                let mesh = egui::Mesh {
520                    indices: vec![0, 1, 2, 2, 3, 0],
521                    vertices: vec![
522                        egui::epaint::Vertex {
523                            pos: rect.left_top(),
524                            uv: egui::epaint::WHITE_UV,
525                            color: left_color,
526                        },
527                        egui::epaint::Vertex {
528                            pos: rect.right_top(),
529                            uv: egui::epaint::WHITE_UV,
530                            color: right_color,
531                        },
532                        egui::epaint::Vertex {
533                            pos: rect.right_bottom(),
534                            uv: egui::epaint::WHITE_UV,
535                            color: right_color,
536                        },
537                        egui::epaint::Vertex {
538                            pos: rect.left_bottom(),
539                            uv: egui::epaint::WHITE_UV,
540                            color: left_color,
541                        },
542                    ],
543                    texture_id: egui::TextureId::default(),
544                };
545                painter.add(egui::Shape::mesh(mesh));
546            }
547        }
548    }
549
550    /// Draw watermark overlay (large symbol name)
551    fn draw_watermark(&self, painter: &egui::Painter, rect: Rect) {
552        if !self.config.show_watermark {
553            return;
554        }
555
556        let text = self.config.watermark_text.as_deref().unwrap_or_else(|| {
557            if self.symbol.is_empty() {
558                "SYMBOL"
559            } else {
560                &self.symbol
561            }
562        });
563
564        let font_id = egui::FontId::proportional(self.config.watermark_font_size);
565
566        // Calculate position based on watermark_pos
567        let pos = match self.config.watermark_pos {
568            WatermarkPos::Center => rect.center(),
569            WatermarkPos::TopLeft => Pos2::new(
570                rect.min.x + 20.0,
571                rect.min.y + self.config.watermark_font_size,
572            ),
573            WatermarkPos::TopRight => Pos2::new(
574                rect.max.x - 20.0,
575                rect.min.y + self.config.watermark_font_size,
576            ),
577            WatermarkPos::BottomLeft => Pos2::new(rect.min.x + 20.0, rect.max.y - 20.0),
578            WatermarkPos::BottomRight => Pos2::new(rect.max.x - 20.0, rect.max.y - 20.0),
579        };
580
581        let anchor = match self.config.watermark_pos {
582            WatermarkPos::Center => egui::Align2::CENTER_CENTER,
583            WatermarkPos::TopLeft => egui::Align2::LEFT_TOP,
584            WatermarkPos::TopRight => egui::Align2::RIGHT_TOP,
585            WatermarkPos::BottomLeft => egui::Align2::LEFT_BOTTOM,
586            WatermarkPos::BottomRight => egui::Align2::RIGHT_BOTTOM,
587        };
588
589        painter.text(pos, anchor, text, font_id, self.config.watermark_color);
590    }
591
592    /// Renders the chart with mouse interactions and optional drawing tools.
593    ///
594    /// This is the mid-level rendering method. Use this when you have drawing
595    /// tools but no separate-pane indicators. For the simplest case, use
596    /// [`Chart::show`]. For full functionality, use [`Chart::show_with_indicators`].
597    pub fn show_with_drawings(
598        &mut self,
599        ui: &mut Ui,
600        drawing_manager: Option<&mut DrawingManager>,
601    ) -> Response {
602        self.show_internal(ui, drawing_manager, None)
603    }
604
605    /// Renders the chart with indicators and drawing tools.
606    ///
607    /// This is the most feature-complete rendering method. Overlay indicators
608    /// (moving averages, Bollinger Bands, etc.) are drawn on the main price chart.
609    /// Separate-pane indicators (RSI, MACD, Stochastic) are rendered in dedicated
610    /// panels below the main chart with aligned x-axes.
611    ///
612    /// After rendering, use [`Chart::get_rendered_indicator_panes`] to access
613    /// indicator pane layout information for hit testing.
614    ///
615    /// # Arguments
616    ///
617    /// * `ui` -- The egui UI to render into
618    /// * `drawing_manager` -- Optional drawing tool manager for trend lines, etc.
619    /// * `indicators` -- Optional indicator registry containing computed indicators
620    pub fn show_with_indicators(
621        &mut self,
622        ui: &mut Ui,
623        drawing_manager: Option<&mut DrawingManager>,
624        indicators: Option<&IndicatorRegistry>,
625    ) -> Response {
626        // Clear previous frame's indicator pane info
627        self.last_rendered_indicator_panes.clear();
628
629        // Calculate total height needed for indicator panes FIRST
630        // This allows us to reserve space before the main chart
631        let indicator_pane_height = if let Some(indicators) = indicators {
632            let mut total_height = 0.0f32;
633            let mut pane_count = 0;
634
635            for indicator in indicators.indicators() {
636                if indicator.is_overlay() || !indicator.is_visible() {
637                    continue;
638                }
639                pane_count += 1;
640                let height = match indicator.name() {
641                    "RSI" => IndicatorPaneConfig::rsi().height,
642                    "MACD" => IndicatorPaneConfig::macd().height,
643                    "Stochastic" => IndicatorPaneConfig::stochastic().height,
644                    _ => IndicatorPaneConfig::default().height,
645                };
646                total_height += height;
647            }
648
649            if pane_count > 0 {
650                // Add minimal gap between panes (seamless panes)
651                total_height + 1.0 * pane_count as f32
652            } else {
653                0.0
654            }
655        } else {
656            0.0
657        };
658
659        // Calculate available height and reserve space for indicators
660        let available = ui.available_size();
661        let main_chart_height = (available.y - indicator_pane_height).max(200.0);
662
663        // Allocate fixed height for main chart (prevents it from taking all space)
664        let response = ui
665            .allocate_ui_with_layout(
666                egui::vec2(available.x, main_chart_height),
667                egui::Layout::top_down(egui::Align::LEFT),
668                |ui| self.show_internal(ui, drawing_manager, indicators),
669            )
670            .inner;
671
672        // Render separate pane indicators below the main chart
673        if let Some(indicators) = indicators {
674            let (start_idx, end_idx) = self.state.visible_range();
675            let visible_range = start_idx..end_idx;
676            let bars = &self.state.data().bars;
677
678            // Get coordinate parameters from time scale for x-axis alignment
679            let time_scale = self.state.time_scale();
680            let coords = IndicatorCoordParams::new(
681                time_scale.bar_spacing(),
682                time_scale.right_offset(),
683                self.state.data().len().saturating_sub(1),
684                start_idx,
685            );
686
687            let mut has_pane_indicators = false;
688            for indicator in indicators.indicators() {
689                if indicator.is_overlay() || !indicator.is_visible() {
690                    continue;
691                }
692                has_pane_indicators = true;
693                break;
694            }
695
696            if has_pane_indicators {
697                // A pane click is resolved after the loop so the immutable `bars`
698                // borrow can be released before mutating selection state. `Some`
699                // means a pane was clicked; the inner value is the element to
700                // select, or `None` to clear (click on empty pane area).
701                let mut pending_pane_selection: Option<Option<ChartElementId>> = None;
702                // Registry index of a pane whose legend "x" was clicked this
703                // frame, if any. Applied after the immutable `bars` borrow ends.
704                let mut pending_indicator_remove: Option<usize> = None;
705                let current_selection = self.selection.selected_id();
706
707                for (idx, indicator) in indicators.indicators().iter().enumerate() {
708                    if indicator.is_overlay() || !indicator.is_visible() {
709                        continue;
710                    }
711
712                    // Minimal gap, no visible separator (seamless panes)
713                    ui.add_space(DESIGN_TOKENS.spacing.hairline);
714
715                    let config = match indicator.name() {
716                        "RSI" => IndicatorPaneConfig::rsi(),
717                        "MACD" => IndicatorPaneConfig::macd(),
718                        "Stochastic" => IndicatorPaneConfig::stochastic(),
719                        _ => IndicatorPaneConfig::default(),
720                    };
721
722                    let mut panel = IndicatorPane::with_config(config);
723
724                    // Use show_aligned_interactive to get pane info for hit testing
725                    if let Some(interaction) = panel.show_aligned_interactive(
726                        ui,
727                        indicator.as_ref(),
728                        bars,
729                        visible_range.clone(),
730                        coords,
731                    ) {
732                        let PaneInteraction {
733                            panel_rect,
734                            chart_rect,
735                            y_min,
736                            y_max,
737                            response: pane_response,
738                            close_response,
739                        } = interaction;
740
741                        // A click on the legend's close "x" requests removal of
742                        // this pane indicator. Recorded by registry index and
743                        // drained once by the host via `take_indicator_remove`.
744                        let close_clicked = close_response.is_some_and(|r| r.clicked());
745                        if close_clicked {
746                            pending_indicator_remove = Some(idx);
747                        }
748
749                        let pane_coords = coords.to_mapping(chart_rect, y_min, y_max);
750
751                        // Resolve a click on this pane: a hit on the line selects
752                        // it, an empty-pane click clears the whole selection. A
753                        // close-"x" click is handled above and must not also
754                        // change the selection.
755                        if !close_clicked
756                            && pane_response.clicked()
757                            && let Some(click_pos) = pane_response.interact_pointer_pos()
758                        {
759                            let hit = hit_test_pane_indicator(
760                                click_pos,
761                                indicator.as_ref(),
762                                idx,
763                                visible_range.clone(),
764                                chart_rect,
765                                y_min,
766                                y_max,
767                                &pane_coords,
768                            );
769                            pending_pane_selection =
770                                Some(hit.map(|h| ChartElementId::PaneIndicator(h.indicator_idx)));
771                        }
772
773                        // Draw selection handles when this pane is selected.
774                        if current_selection == Some(ChartElementId::PaneIndicator(idx)) {
775                            let dot_config = SelectionDotConfig {
776                                dot_interval: calculate_dot_interval(pane_coords.bar_spacing),
777                                ..Default::default()
778                            };
779                            for line_idx in 0..indicator.line_cnt() {
780                                render_indicator_selection_dots(
781                                    ui.painter(),
782                                    indicator.as_ref(),
783                                    line_idx,
784                                    visible_range.clone(),
785                                    &pane_coords,
786                                    |value| pane_coords.price_to_y(value),
787                                    &dot_config,
788                                );
789                            }
790                        }
791
792                        // Store the pane info for hit testing by platform
793                        self.last_rendered_indicator_panes
794                            .push(RenderedIndicatorPane {
795                                indicator_idx: idx,
796                                panel_rect,
797                                chart_rect,
798                                y_min,
799                                y_max,
800                                coords,
801                            });
802                    }
803                }
804
805                // Apply the deferred pane selection now that `bars` is no longer
806                // borrowed.
807                if let Some(decision) = pending_pane_selection {
808                    match decision {
809                        Some(id) => self.selection.select(id, None),
810                        None => self.selection.deselect(),
811                    }
812                }
813
814                // Record a pane remove request for the host to drain. The chart
815                // does not own the registry, so it cannot remove the indicator
816                // itself; it surfaces the index via `take_indicator_remove`.
817                if pending_indicator_remove.is_some() {
818                    self.indicator_remove = pending_indicator_remove;
819                }
820            }
821        }
822
823        response
824    }
825
826    /// Renders the chart with indicators using simple per-bar x-positioning.
827    ///
828    /// Unlike [`Chart::show_with_indicators`], which uses aligned coordinate
829    /// parameters from the main chart's time scale, this method creates
830    /// indicator panes with basic visible-range positioning. It is simpler
831    /// but may not perfectly align indicator data points with the main chart
832    /// when the user scrolls or zooms. Prefer [`Chart::show_with_indicators`]
833    /// for production use.
834    pub fn show_with_indicators_plot(
835        &mut self,
836        ui: &mut Ui,
837        drawing_manager: Option<&mut DrawingManager>,
838        indicators: Option<&IndicatorRegistry>,
839    ) -> Response {
840        let response = self.show_with_drawings(ui, drawing_manager);
841
842        if let Some(indicators) = indicators {
843            ui.separator();
844
845            let (start_idx, end_idx) = self.state.visible_range();
846            let visible_range = start_idx..end_idx;
847            let bars = &self.state.data().bars;
848
849            for indicator in indicators.indicators() {
850                if indicator.is_overlay() {
851                    continue;
852                }
853
854                if !indicator.is_visible() {
855                    continue;
856                }
857
858                let config = match indicator.name() {
859                    "RSI" => IndicatorPaneConfig::rsi(),
860                    "MACD" => IndicatorPaneConfig::macd(),
861                    "Stochastic" => IndicatorPaneConfig::stochastic(),
862                    _ => IndicatorPaneConfig::default(),
863                };
864
865                let mut panel = IndicatorPane::with_config(config);
866
867                panel.show(ui, indicator.as_ref(), bars, visible_range.clone());
868            }
869        }
870
871        response
872    }
873
874    /// Renders the chart with standard mouse interactions.
875    ///
876    /// This is the simplest way to display a chart. It handles pan, zoom,
877    /// crosshair, keyboard shortcuts, and all visual elements configured in
878    /// [`ChartConfig`]. No drawing tools or separate-pane indicators are rendered.
879    ///
880    /// Returns an [`egui::Response`] for additional interaction handling.
881    ///
882    /// # Example
883    ///
884    /// ```rust,ignore
885    /// egui::CentralPanel::default().show(ctx, |ui| {
886    ///     let response = chart.show(ui);
887    ///     if response.hovered() {
888    ///         // Chart is being hovered
889    ///     }
890    /// });
891    /// ```
892    pub fn show(&mut self, ui: &mut Ui) -> Response {
893        self.show_internal(ui, None, None)
894    }
895
896    /// Resolve the timeframe used to pick a session-break granularity.
897    ///
898    /// The legend `timeframe` label is the primary source (it parses forms like
899    /// `"1H"`, `"15min"`, `"1D"`). When it is empty or unrecognized, the cadence
900    /// is inferred from the visible bars by taking the median gap between
901    /// consecutive timestamps and snapping it to the nearest preset, so session
902    /// breaks work even when the host never set a label. Falls back to the
903    /// default (1-minute) when there are too few bars to measure.
904    fn resolve_session_timeframe(
905        &self,
906        visible_data: &[crate::model::Bar],
907    ) -> crate::model::Timeframe {
908        use crate::model::Timeframe;
909        use std::str::FromStr;
910
911        if let Ok(tf) = Timeframe::from_str(self.timeframe.trim()) {
912            return tf;
913        }
914
915        // Infer from the median inter-bar gap (robust to session-edge jumps).
916        if visible_data.len() < 2 {
917            return Timeframe::default();
918        }
919        let mut gaps_ms: Vec<i64> = visible_data
920            .windows(2)
921            .map(|w| (w[1].time - w[0].time).num_milliseconds())
922            .filter(|&g| g > 0)
923            .collect();
924        if gaps_ms.is_empty() {
925            return Timeframe::default();
926        }
927        gaps_ms.sort_unstable();
928        let median = gaps_ms[gaps_ms.len() / 2];
929
930        // Snap to the closest preset by duration.
931        Timeframe::all()
932            .into_iter()
933            .min_by_key(|tf| (tf.duration_ms() - median).abs())
934            .unwrap_or_default()
935    }
936
937    /// Internal rendering method that orchestrates all modules
938    pub(crate) fn show_internal(
939        &mut self,
940        ui: &mut Ui,
941        mut drawing_manager: Option<&mut DrawingManager>,
942        indicators: Option<&IndicatorRegistry>,
943    ) -> Response {
944        // Register egui's image loaders once per context so the embedded SVG
945        // icons used by the toolbars and panels decode without the host app
946        // having to wire egui_extras itself.
947        let ctx = ui.ctx().clone();
948        let loaders_installed = egui::Id::new("egui_charts::image_loaders_installed");
949        if !ctx.data(|d| d.get_temp::<bool>(loaders_installed).unwrap_or(false)) {
950            egui_extras::install_image_loaders(&ctx);
951            ctx.data_mut(|d| d.insert_temp(loaders_installed, true));
952        }
953
954        // Reset zoom_just_applied flag at the start of each frame
955        self.zoom_just_applied = false;
956
957        // A right-click result lives for exactly one frame: clear any stale
958        // capture so a target that is never drained does not reappear later.
959        self.right_click = None;
960
961        let available_size = ui.available_size();
962        let (mut response, painter) = ui.allocate_painter(available_size, Sense::click_and_drag());
963        let rect = response.rect;
964
965        // Establish chart_rect FIRST before any operations
966        let top_padding = if self.config.show_ohlc_info {
967            sizing::chart::TOP_PADDING_WITH_OHLC
968        } else {
969            sizing::chart::TOP_PADDING_NO_OHLC
970        };
971        let bottom_padding = if self.config.show_time_labels {
972            sizing::chart::BOTTOM_PADDING_WITH_TIME
973        } else {
974            sizing::chart::BOTTOM_PADDING_NO_TIME
975        };
976        let right_axis_width = sizing::chart::RIGHT_AXIS_WIDTH;
977
978        let left_margin = sizing::chart::PADDING;
979        let right_margin = sizing::chart::PADDING + right_axis_width;
980
981        let chart_rect = Rect::from_min_size(
982            rect.min + Vec2::new(left_margin, top_padding),
983            Vec2::new(
984                (rect.width() - left_margin - right_margin).max(sizing::chart::MIN_CHART_WIDTH),
985                (rect.height() - top_padding - bottom_padding).max(sizing::chart::MIN_CHART_HEIGHT),
986            ),
987        );
988
989        let chart_width = chart_rect.width();
990
991        // CRITICAL: Apply TimeScale width configuration BEFORE any zoom operations
992        // This ensures apply_constraints() inside zoom() uses the correct self.width
993        // to calculate constraint bounds. Without this, drawings drift during zoom
994        // because constraints are calculated with stale width values.
995        self.apply_timescale_config(chart_width);
996
997        // Handle tracking mode
998        self.handle_tracking_mode(ui, &response);
999
1000        // Request focus on hover for keyboard shortcuts
1001        self.request_focus_if_needed(&mut response);
1002
1003        // Handle keyboard shortcuts
1004        self.handle_keyboard_shortcuts(ui, &response, chart_width, chart_rect.min.x);
1005
1006        // Calculate visible bars
1007        let logical_range = self.state.time_scale().visible_logical_range();
1008        let visible_bars = logical_range.length().ceil() as usize;
1009        self.last_visible_bars = visible_bars;
1010
1011        // Show grabbing cursor during panning
1012        self.set_panning_cursor(ui, &response);
1013
1014        // Define axis rects
1015        let price_axis_rect = Rect::from_min_max(
1016            Pos2::new(chart_rect.max.x, chart_rect.min.y),
1017            Pos2::new(rect.max.x, chart_rect.max.y),
1018        );
1019        let time_axis_rect = Rect::from_min_max(
1020            Pos2::new(chart_rect.min.x, chart_rect.max.y),
1021            Pos2::new(chart_rect.max.x, rect.max.y),
1022        );
1023
1024        // Handle double-click to reset axes
1025        self.handle_double_click(&response, price_axis_rect, time_axis_rect);
1026
1027        // Handle mouse wheel for zoom/scroll
1028        // Block pan/zoom when drawing tool is active OR when manipulating a drawing
1029        let is_drawing_interaction = drawing_manager.as_ref().is_some_and(|dm| {
1030            dm.active_tool.is_some() || dm.dragging_handle.is_some() || dm.curr_drawing.is_some()
1031        });
1032        let pending_price_zoom = self.handle_mouse_wheel(
1033            ui,
1034            &response,
1035            chart_width,
1036            chart_rect.min.x,
1037            price_axis_rect,
1038        );
1039
1040        // Handle pinch-to-zoom for touch/trackpad gestures
1041        self.handle_pinch_zoom(ui, &response, chart_width, chart_rect.min.x);
1042
1043        // Handle drag to pan (blocked when interacting with drawings)
1044        self.handle_drag_pan(
1045            ui,
1046            &response,
1047            price_axis_rect,
1048            time_axis_rect,
1049            chart_rect.min.x,
1050            is_drawing_interaction,
1051        );
1052
1053        // Apply kinetic scrolling
1054        self.apply_kinetic_scroll(ui);
1055
1056        // Handle box zoom (only when zoom mode is active from toolbar)
1057        // Right-click is reserved for context menu, zoom uses left-click when mode is active
1058        self.zoom_just_applied = self.handle_box_zoom(
1059            ui,
1060            &response,
1061            chart_rect,
1062            chart_width,
1063            self.zoom_mode_active,
1064        );
1065        if self.zoom_just_applied {
1066            log::info!("Zoom applied - chart will auto-deactivate zoom mode");
1067        }
1068
1069        // Set zoom-in cursor when zoom mode is active
1070        if self.zoom_mode_active && response.hovered() {
1071            ui.ctx().set_cursor_icon(egui::CursorIcon::ZoomIn);
1072        }
1073        // Set cursor icon based on crosshair style (cursor type)
1074        // This is only for default case - drawing manager may override for eraser mode
1075        else if response.hovered() {
1076            use crate::config::CrosshairStyle;
1077            match self.chart_options.crosshair.style {
1078                CrosshairStyle::Full => {
1079                    // Cross cursor mode - show crosshair cursor
1080                    ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
1081                }
1082                CrosshairStyle::Dot => {
1083                    // Dot mode - default pointer (dot is rendered on chart)
1084                    ui.ctx().set_cursor_icon(egui::CursorIcon::Default);
1085                }
1086                CrosshairStyle::Arrow => {
1087                    // Arrow mode - default pointer
1088                    ui.ctx().set_cursor_icon(egui::CursorIcon::Default);
1089                }
1090            }
1091        }
1092
1093        // Draw background (solid or gradient)
1094        self.draw_background(&painter, rect);
1095
1096        // Draw watermark overlay (if enabled)
1097        self.draw_watermark(&painter, chart_rect);
1098
1099        if self.state.data().is_empty() {
1100            painter.text(
1101                rect.center(),
1102                egui::Align2::CENTER_CENTER,
1103                "No data available",
1104                egui::FontId::proportional(typography::LG),
1105                self.config.text_color,
1106            );
1107            return response;
1108        }
1109
1110        // Split chart area into price and volume sections
1111        let (price_rect, volume_rect) = if self.config.show_volume {
1112            let split_y =
1113                chart_rect.min.y + chart_rect.height() * (1.0 - self.config.volume_height_fraction);
1114            (
1115                Rect::from_min_max(chart_rect.min, Pos2::new(chart_rect.max.x, split_y)),
1116                Rect::from_min_max(Pos2::new(chart_rect.min.x, split_y), chart_rect.max),
1117            )
1118        } else {
1119            (chart_rect, Rect::ZERO)
1120        };
1121
1122        // Get visible range
1123        let (start_idx, _end_idx) = self.state.visible_range();
1124        self.start_idx = start_idx;
1125
1126        // Capture near_live status for button
1127        let near_live_edge = self.state.time_scale().right_offset() >= -1.5;
1128
1129        // Handle "Jump to Latest" button interaction
1130        if !near_live_edge {
1131            let btn_size = Vec2::new(
1132                DESIGN_TOKENS.sizing.charts_ext.realtime_button_width,
1133                DESIGN_TOKENS.sizing.button_md,
1134            );
1135            let btn_pos = Pos2::new(
1136                price_rect.center().x - btn_size.x / 2.0,
1137                price_rect.min.y + DESIGN_TOKENS.spacing.lg + DESIGN_TOKENS.spacing.xs,
1138            );
1139            let btn_rect = Rect::from_min_size(btn_pos, btn_size);
1140            let btn_id = ui.id().with("jump_to_latest");
1141            let btn_res = ui.interact(btn_rect, btn_id, egui::Sense::click());
1142
1143            if btn_res.clicked() {
1144                self.state.time_scale_mut().scroll_to_realtime();
1145            }
1146        }
1147
1148        // Determine price bounds
1149        let (mut adjusted_min, mut adjusted_max) = self.state.price_range();
1150
1151        // Apply price zoom
1152        let (new_min, new_max) = self.apply_price_zoom(
1153            pending_price_zoom,
1154            &response,
1155            chart_rect,
1156            adjusted_min,
1157            adjusted_max,
1158        );
1159        adjusted_min = new_min;
1160        adjusted_max = new_max;
1161
1162        // Store the final rendered price range and rects for external use (selection dots, hit testing)
1163        self.last_rendered_price_range = (adjusted_min, adjusted_max);
1164        self.last_rendered_price_rect = price_rect;
1165        self.last_rendered_volume_rect = volume_rect;
1166
1167        // Get visible data
1168        let visible_data = self.state.visible_data();
1169
1170        if visible_data.is_empty() {
1171            return response;
1172        }
1173
1174        // Calculate volume range
1175        let max_volume = if self.config.show_volume {
1176            visible_data
1177                .iter()
1178                .map(|c| c.volume)
1179                .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
1180                .unwrap_or(1.0)
1181        } else {
1182            1.0
1183        };
1184
1185        // Draw grid
1186        if self.config.show_horizontal_grid {
1187            rendering::render_grid(
1188                &painter,
1189                price_rect,
1190                adjusted_min,
1191                adjusted_max,
1192                self.config.grid_color,
1193            );
1194        }
1195
1196        // Create rendering contexts
1197        let bar_spacing = self.state.time_scale().bar_spacing();
1198        let bar_width = bar_spacing * self.config.candle_width;
1199        let price_ctx = RenderContext::new(&painter, price_rect);
1200        let price_scale = PriceScale::new(adjusted_min, adjusted_max);
1201        let coords = ChartMapping::new(
1202            price_rect,
1203            bar_spacing,
1204            start_idx,
1205            self.state.time_scale().base_idx(),
1206            self.state.time_scale().right_offset(),
1207            adjusted_min,
1208            adjusted_max,
1209        );
1210        let colors = StyleColors {
1211            bullish: self.config.bullish_color,
1212            bearish: self.config.bearish_color,
1213            text: self.config.text_color,
1214            bullish_border: self.config.bullish_border_color,
1215            bearish_border: self.config.bearish_border_color,
1216            bullish_wick: self.config.bullish_wick_color,
1217            bearish_wick: self.config.bearish_wick_color,
1218            candle_border_width: self.config.candle_border_width,
1219        };
1220
1221        let formatter = if self.config.show_time_labels || self.config.show_vertical_grid {
1222            Some(
1223                TimeFormatterBuilder::new()
1224                    .with_24_hour(true)
1225                    .with_seconds(true)
1226                    .with_timezone(self.chart_options.time_scale.timezone.clone())
1227                    .build(),
1228            )
1229        } else {
1230            None
1231        };
1232
1233        if self.config.show_vertical_grid {
1234            // Simple bar-index-based vertical grid - moves 1:1 with chart
1235            rendering::render_vertical_grid(&painter, chart_rect, &coords, self.config.grid_color);
1236        }
1237
1238        // Session-break layer: day/session dividers and optional alternating
1239        // session shading. Drawn here — after the grid, before the candles — so
1240        // the shading sits behind the bars and the dividers stay subtle. The
1241        // boundary granularity follows the timeframe: day changes intraday, week
1242        // changes on daily bars, month changes on weekly/monthly bars. Painters
1243        // are clipped to the price rect so neither bleeds onto the axes.
1244        if self.config.show_session_breaks {
1245            let session_tf = self.resolve_session_timeframe(visible_data);
1246            let provider = renderers::provider_for_timeframe(session_tf);
1247            let session_painter = painter.with_clip_rect(price_rect);
1248            let session_ctx = RenderContext::new(&session_painter, price_rect);
1249
1250            // Subtle alternating shading behind the candles (still gated by the
1251            // same flag; the even span is transparent so the effect reads as a
1252            // faint banding rather than two competing fills).
1253            let background =
1254                renderers::SessionBackgroundRenderer::from_background(self.config.background_color);
1255            background.render(
1256                &session_ctx,
1257                visible_data,
1258                provider.as_ref(),
1259                &coords,
1260                start_idx,
1261            );
1262
1263            let break_renderer =
1264                renderers::SessionBreakRenderer::new(renderers::SessionBreakRenderConfig {
1265                    line_color: self.config.session_break_color,
1266                    line_width: 1.0,
1267                    style: self.config.session_break_style,
1268                });
1269            break_renderer.render(
1270                &session_ctx,
1271                visible_data,
1272                provider.as_ref(),
1273                &coords,
1274                start_idx,
1275            );
1276        }
1277
1278        // Render chart type with clipping to prevent bars from overlapping axes
1279        // CRITICAL: Use chart_rect.width() for consistency with drawing coordinate system
1280        let chart_rect_width = chart_rect.width();
1281        let idx_to_coord = |idx: usize, min_x: f32| -> f32 {
1282            self.state
1283                .time_scale()
1284                .idx_to_coord(idx, min_x, chart_rect_width)
1285        };
1286
1287        // Create clipped painter and contexts to prevent bars from rendering on axes
1288        let clipped_painter = painter.with_clip_rect(chart_rect);
1289        let clipped_price_ctx = RenderContext::new(&clipped_painter, price_rect);
1290        let clipped_volume_ctx = RenderContext::new(&clipped_painter, volume_rect);
1291
1292        let render_ctx = rendering::CandleDataContext {
1293            price_ctx: &clipped_price_ctx,
1294            volume_ctx: &clipped_volume_ctx,
1295            price_scale: &price_scale,
1296            colors: &colors,
1297            visible_data,
1298            // Full dataset so window-independent transforms (Heikin-Ashi) can be
1299            // computed over the complete series, not just the visible slice.
1300            full_data: &self.state.data().bars,
1301            start_idx,
1302        };
1303
1304        let render_params = rendering::ChartTypeParams::new(
1305            rendering::BarDimensions::new(bar_width, self.config.wick_width),
1306            rendering::VolumeSettings::new(self.config.show_volume, max_volume),
1307            rendering::JapaneseChartSettings::new(self.renko_brick_size, self.kagi_reversal_amount),
1308            rendering::TradingColors::new(self.config.bullish_color, self.config.bearish_color),
1309            rendering::CoordMapping::new(chart_rect.min.x),
1310            self.config.price_source,
1311        );
1312
1313        rendering::render_chart_type(self.chart_type, &render_ctx, &render_params, idx_to_coord);
1314
1315        // Draw indicators
1316        if let Some(indicator_registry) = indicators {
1317            renderers::IndicatorRenderer::render(
1318                &price_ctx,
1319                indicator_registry.indicators(),
1320                visible_data,
1321                &price_scale,
1322                &coords,
1323            );
1324        }
1325
1326        // Resolve a click on the main chart area into a selection. Overlay
1327        // indicators take priority over the series beneath them; an empty-area
1328        // click clears the selection. Pane-indicator clicks are resolved by
1329        // show_with_indicators, which renders the panes outside this rect.
1330        let main_visible_range = start_idx..(start_idx + visible_data.len());
1331        if response.clicked()
1332            && let Some(click_pos) = response.interact_pointer_pos()
1333        {
1334            let volume_coords = if self.config.show_volume {
1335                Some(coords.with_rect(volume_rect))
1336            } else {
1337                None
1338            };
1339            let hit = hit_test_main_chart(
1340                click_pos,
1341                indicators,
1342                &coords,
1343                volume_coords.as_ref(),
1344                &self.state.data().bars,
1345                max_volume,
1346                main_visible_range.clone(),
1347            );
1348            match hit {
1349                Some((id, bar_idx)) => self.selection.select(id, Some(bar_idx)),
1350                None => self.selection.deselect(),
1351            }
1352        }
1353
1354        // Resolve a right-click into a context-menu target. Mirrors the
1355        // left-click priority (drawing > overlay indicator > series), and
1356        // right-clicking an object also selects it so the menu and the
1357        // selection handles agree, matching TradingView. The captured target is
1358        // drained by the host via `take_right_click`, or consumed by the
1359        // turnkey menu in `TradingChart`.
1360        if response.secondary_clicked()
1361            && let Some(click_pos) = response.interact_pointer_pos()
1362            && chart_rect.contains(click_pos)
1363        {
1364            let price = coords.y_to_price(click_pos.y);
1365
1366            // Drawings sit on top of the series, so they take priority. The
1367            // drawing manager is reborrowed here; it is moved into
1368            // `handle_drawings` later in the frame.
1369            let drawing_hit = drawing_manager
1370                .as_deref()
1371                .and_then(|dm| dm.hit_test(click_pos));
1372
1373            self.right_click = Some(if let Some(drawing_id) = drawing_hit {
1374                // Right-clicking a drawing selects it (TradingView parity) and
1375                // clears any series/indicator selection so handles don't show
1376                // on two objects at once.
1377                self.selection.deselect();
1378                if let Some(dm) = drawing_manager.as_deref_mut() {
1379                    dm.select(drawing_id);
1380                }
1381                RightClickTarget::Drawing {
1382                    drawing_id,
1383                    pos: click_pos,
1384                }
1385            } else {
1386                let volume_coords = if self.config.show_volume {
1387                    Some(coords.with_rect(volume_rect))
1388                } else {
1389                    None
1390                };
1391                let element_hit = hit_test_main_chart(
1392                    click_pos,
1393                    indicators,
1394                    &coords,
1395                    volume_coords.as_ref(),
1396                    &self.state.data().bars,
1397                    max_volume,
1398                    main_visible_range.clone(),
1399                );
1400                match element_hit {
1401                    Some((id, bar_idx)) => {
1402                        self.selection.select(id, Some(bar_idx));
1403                        RightClickTarget::Element {
1404                            id,
1405                            bar_idx,
1406                            pos: click_pos,
1407                            price,
1408                        }
1409                    }
1410                    None => {
1411                        self.selection.deselect();
1412                        RightClickTarget::Background {
1413                            pos: click_pos,
1414                            price,
1415                        }
1416                    }
1417                }
1418            });
1419        }
1420
1421        // Draw selection handles for whichever element is currently selected on
1422        // the main chart (overlay indicator line or series data points).
1423        self.render_main_chart_selection(
1424            &painter,
1425            indicators,
1426            &coords,
1427            visible_data,
1428            main_visible_range,
1429        );
1430
1431        // Render bar marks (Widget API annotations)
1432        if !self.marks.is_empty() {
1433            renderers::render_markers(
1434                &clipped_price_ctx,
1435                &self.marks,
1436                visible_data,
1437                &price_scale,
1438                &coords,
1439            );
1440        }
1441
1442        // Draw price labels
1443        if self.config.show_right_axis {
1444            rendering::render_price_labels(
1445                &price_ctx,
1446                &price_scale,
1447                &colors,
1448                crate::scales::PriceScaleMode::Normal,
1449            );
1450        }
1451
1452        // Last price line & label
1453        if self.config.show_symbol_last_val
1454            && let Some(last) = visible_data.last()
1455        {
1456            rendering::render_last_price_line(
1457                &painter,
1458                price_rect,
1459                last.close,
1460                last.open,
1461                adjusted_min,
1462                adjusted_max,
1463                self.config.bullish_color,
1464                self.config.bearish_color,
1465                self.config.show_right_axis,
1466            );
1467        }
1468
1469        // Draw time labels
1470        if self.config.show_time_labels {
1471            let chart_ctx = RenderContext::new(&painter, chart_rect);
1472            rendering::render_time_labels(
1473                &chart_ctx,
1474                visible_data,
1475                &coords,
1476                &colors,
1477                formatter.as_deref(),
1478            );
1479        }
1480
1481        // Draw OHLC info header (legend if symbol is set)
1482        if self.config.show_ohlc_info {
1483            if !self.symbol.is_empty() {
1484                // Calculate prev_close from second-to-last bar for change calculation
1485                let prev_close = if visible_data.len() >= 2 {
1486                    Some(visible_data[visible_data.len() - 2].close)
1487                } else {
1488                    None
1489                };
1490                rendering::render_legend(
1491                    &painter,
1492                    rect,
1493                    &self.symbol,
1494                    &self.timeframe,
1495                    visible_data,
1496                    prev_close,
1497                    &colors,
1498                    sizing::chart::PADDING,
1499                );
1500            } else {
1501                // Fallback to basic OHLC info
1502                rendering::render_ohlc_info(
1503                    &painter,
1504                    rect,
1505                    visible_data,
1506                    sizing::chart::PADDING,
1507                    self.config.text_color,
1508                );
1509            }
1510        }
1511
1512        // Handle drawing tools
1513        if let Some(dm) = drawing_manager {
1514            // Clone/extract values before mutable borrow of self
1515            let timescale = self.state.time_scale().clone();
1516            let last_close = visible_data.last().map(|b| b.close);
1517
1518            // Temporarily take cursor_modes to avoid borrow conflict
1519            let mut cursor_modes = std::mem::take(&mut self.cursor_modes);
1520
1521            self.handle_drawings(
1522                ui,
1523                dm,
1524                &mut cursor_modes,
1525                &response,
1526                price_rect,
1527                adjusted_min,
1528                adjusted_max,
1529                &painter,
1530                last_close,
1531                &timescale,
1532            );
1533
1534            // Render eraser highlight if in eraser mode
1535            self.render_eraser_highlight(&painter, dm, &cursor_modes);
1536
1537            // Put cursor_modes back
1538            self.cursor_modes = cursor_modes;
1539        }
1540
1541        // Render "Jump to Latest" button
1542        if self.config.show_realtime_btn {
1543            let btn_id = ui.id().with("jump_to_latest");
1544            let btn_size = Vec2::new(
1545                DESIGN_TOKENS.sizing.charts_ext.realtime_button_width,
1546                DESIGN_TOKENS.sizing.button_md,
1547            );
1548            let btn_pos = Pos2::new(
1549                price_rect.center().x - btn_size.x / 2.0,
1550                price_rect.min.y + DESIGN_TOKENS.spacing.lg + DESIGN_TOKENS.spacing.xs,
1551            );
1552            let btn_rect = Rect::from_min_size(btn_pos, btn_size);
1553            let btn_res = ui.interact(btn_rect, btn_id, egui::Sense::click());
1554
1555            rendering::render_realtime_btn(
1556                &painter,
1557                price_rect,
1558                near_live_edge,
1559                self.config.show_realtime_btn,
1560                self.config.realtime_button_size,
1561                self.config.realtime_button_pos,
1562                self.config.realtime_button_color,
1563                self.config.realtime_button_hover_color,
1564                self.config.realtime_button_text_color,
1565                self.config.realtime_button_text.as_deref(),
1566                btn_res.hovered(),
1567            );
1568        }
1569
1570        // Draw crosshair with options from chart_options
1571        if let Some(hover_pos) = response.hover_pos()
1572            && price_rect.contains(hover_pos)
1573        {
1574            // Cache the hover bar index for multi-chart sync
1575            self.last_hover_bar_idx = Some(coords.x_to_idx_f32(hover_pos.x) as f64);
1576
1577            rendering::render_crosshair_with_options(
1578                &price_ctx,
1579                hover_pos,
1580                visible_data,
1581                &price_scale,
1582                &coords,
1583                &self.chart_options.crosshair,
1584            );
1585
1586            // Draw the OHLC/value readout for the bar under the cursor, matching
1587            // the bar the crosshair snaps to. Resolve via the shared coordinate
1588            // mapping so the readout and the crosshair never disagree, and skip
1589            // it when the cursor is in the empty scroll margin past the data.
1590            let tooltip_options = &self.chart_options.tooltip;
1591            if tooltip_options.enabled
1592                && let Some(local_idx) = coords.local_idx_at_x(hover_pos.x, visible_data.len())
1593            {
1594                let candle = &visible_data[local_idx];
1595                rendering::render_tooltip_with_options(
1596                    &price_ctx,
1597                    hover_pos,
1598                    candle,
1599                    tooltip_options,
1600                    &price_scale,
1601                    &coords,
1602                    visible_data,
1603                );
1604            }
1605        } else {
1606            // Clear hover bar index when not hovering locally
1607            self.last_hover_bar_idx = None;
1608
1609            // Render synced crosshair from other charts (if available)
1610            {
1611                if let Some(bar_idx) = self.synced_crosshair_bar_idx {
1612                    // Convert bar index to screen x coordinate
1613                    let x = coords.idx_to_x(bar_idx as usize);
1614                    if coords.is_x_visible(x) {
1615                        // Create a synthetic hover position at the center of the price range
1616                        let center_y = price_rect.center().y;
1617                        let synced_pos = Pos2::new(x, center_y);
1618
1619                        rendering::render_crosshair_with_options(
1620                            &price_ctx,
1621                            synced_pos,
1622                            visible_data,
1623                            &price_scale,
1624                            &coords,
1625                            &self.chart_options.crosshair,
1626                        );
1627                    }
1628                }
1629            }
1630        }
1631
1632        // Draw box zoom rect
1633        rendering::render_box_zoom(&painter, &self.box_zoom);
1634
1635        // Focus ring for keyboard accessibility
1636        crate::styles::focus::draw_focus_ring(ui, &response);
1637
1638        response
1639    }
1640
1641    // =========================================================================
1642    // Multi-Chart Sync Methods
1643    // =========================================================================
1644
1645    /// Sets an external crosshair position from a synced chart (in bar-index coordinates).
1646    ///
1647    /// When set to `Some(idx)`, a crosshair is drawn at the given bar index even
1648    /// if the user is not hovering over this chart. Pass `None` to clear it.
1649    /// This is the receiver side of multi-chart crosshair synchronization.
1650    pub fn set_synced_crosshair_bar_idx(&mut self, bar_idx: Option<f64>) {
1651        self.synced_crosshair_bar_idx = bar_idx;
1652    }
1653
1654    /// Returns the bar index that the user was hovering over in the last frame.
1655    ///
1656    /// Returns `None` if the cursor was not over the chart. This is the emitter
1657    /// side of multi-chart crosshair synchronization: read this value and pass
1658    /// it to [`Chart::set_synced_crosshair_bar_idx`] on other charts.
1659    pub fn get_hover_bar_idx(&self) -> Option<f64> {
1660        self.last_hover_bar_idx
1661    }
1662
1663    /// Applies time-scale state from another chart for synchronized scrolling/zooming.
1664    ///
1665    /// Sets both bar spacing and right offset to match the source chart so that
1666    /// both charts display the same time range. Use together with
1667    /// [`Chart::get_time_scale_state`] on the source chart.
1668    pub fn apply_synced_time_scale(&mut self, bar_spacing: f32, right_offset: f32) {
1669        self.state.time_scale_mut().set_bar_spacing(bar_spacing);
1670        self.state.time_scale_mut().set_right_offset(right_offset);
1671    }
1672
1673    /// Returns the current time-scale state as `(bar_spacing, right_offset)`.
1674    ///
1675    /// This is the emitter side of multi-chart time-scale synchronization.
1676    /// Pass the returned values to [`Chart::apply_synced_time_scale`] on other
1677    /// charts to keep them scrolled/zoomed in unison.
1678    pub fn get_time_scale_state(&self) -> (f32, f32) {
1679        (
1680            self.state.time_scale().bar_spacing(),
1681            self.state.time_scale().right_offset(),
1682        )
1683    }
1684
1685    /// Get a [`ChartMapping`] for coordinate conversions.
1686    ///
1687    /// Returns a mapping constructed from the last rendered frame's parameters
1688    /// (price rect, bar spacing, right offset, price range). This is used for
1689    /// converting between screen coordinates and data coordinates, particularly
1690    /// for drawing tool restoration and hit testing.
1691    pub fn get_chart_mapping(&self) -> ChartMapping {
1692        ChartMapping::new(
1693            self.last_rendered_price_rect,
1694            self.state.time_scale().bar_spacing(),
1695            self.start_idx,
1696            self.state.time_scale().base_idx(),
1697            self.state.time_scale().right_offset(),
1698            self.last_rendered_price_range.0,
1699            self.last_rendered_price_range.1,
1700        )
1701    }
1702
1703    // =========================================================================
1704    // Selection API
1705    // =========================================================================
1706
1707    /// Returns the chart element the user has currently selected, if any.
1708    ///
1709    /// Selection is driven by clicking a series, an overlay indicator line, or a
1710    /// separate-pane indicator line. Host apps read this to open a settings
1711    /// dialog for, or delete, the selected object. Returns `None` when nothing
1712    /// is selected (for example after a click on empty chart area).
1713    pub fn selected_element(&self) -> Option<ChartElementId> {
1714        self.selection.selected_id()
1715    }
1716
1717    /// Returns the bar index at which the current selection was made, if any.
1718    ///
1719    /// This is the data-space index of the segment that was clicked, useful for
1720    /// anchoring tooltips or context menus near the click.
1721    pub fn selected_bar(&self) -> Option<usize> {
1722        self.selection.selected_bar()
1723    }
1724
1725    /// Clears any current selection.
1726    ///
1727    /// Equivalent to clicking empty chart area. Call this after the host app has
1728    /// finished acting on a selection (e.g. closing a settings dialog).
1729    pub fn clear_selection(&mut self) {
1730        self.selection.deselect();
1731    }
1732
1733    /// Drains the right-click target captured during the last frame, if any.
1734    ///
1735    /// Returns `Some` exactly once per right-click: the chart hit-tests the
1736    /// cursor against drawings, indicators, and series (same priority as
1737    /// left-click selection) and records where the click landed. The hit object
1738    /// is also selected, so selection handles and any context menu stay
1739    /// consistent.
1740    ///
1741    /// Hosts that build their own context menus read this each frame and open
1742    /// the appropriate menu at [`RightClickTarget`]`::pos`. The turnkey menu in
1743    /// [`TradingChart`](crate::TradingChart) consumes it for you.
1744    ///
1745    /// Carries no `ui`-feature types, so it is available with
1746    /// `--no-default-features`.
1747    pub fn take_right_click(&mut self) -> Option<RightClickTarget> {
1748        self.right_click.take()
1749    }
1750
1751    /// Drains a pane-indicator remove request captured during the last frame.
1752    ///
1753    /// Returns `Some(index)` exactly once when the user clicks the close "x" on
1754    /// an indicator pane's legend, where `index` is the indicator's position in
1755    /// the registry passed to [`Chart::show_with_indicators`]. The chart does
1756    /// not own the registry, so the host performs the actual removal (e.g.
1757    /// [`IndicatorRegistry::remove_indicator`](crate::studies::IndicatorRegistry::remove_indicator)
1758    /// followed by a recompute); the pane layout reflows on the next frame.
1759    ///
1760    /// Carries no `ui`-feature types, so it is available with
1761    /// `--no-default-features`.
1762    pub fn take_indicator_remove(&mut self) -> Option<usize> {
1763        self.indicator_remove.take()
1764    }
1765
1766    /// Returns a mutable reference to the chart's visual configuration.
1767    ///
1768    /// Use this to apply settings produced by a dialog in place, e.g.
1769    /// `settings.apply_to_config(chart.config_mut())`, without rebuilding the
1770    /// whole [`ChartConfig`]. Changes take effect on the next rendered frame.
1771    pub fn config_mut(&mut self) -> &mut ChartConfig {
1772        &mut self.config
1773    }
1774
1775    /// Draws selection handles for the element currently selected on the main
1776    /// chart (an overlay indicator line or a price/volume series).
1777    ///
1778    /// Pane-indicator handles are drawn separately by `show_with_indicators`
1779    /// since their panes live outside the main chart rect.
1780    fn render_main_chart_selection(
1781        &self,
1782        painter: &egui::Painter,
1783        indicators: Option<&IndicatorRegistry>,
1784        coords: &ChartMapping,
1785        visible_data: &[crate::model::Bar],
1786        visible_range: std::ops::Range<usize>,
1787    ) {
1788        let Some(selected) = self.selection.selected_id() else {
1789            return;
1790        };
1791
1792        match selected {
1793            ChartElementId::OverlayIndicator(idx) => {
1794                let Some(registry) = indicators else { return };
1795                let Some(indicator) = registry.indicators().get(idx) else {
1796                    return;
1797                };
1798                if !indicator.is_visible() || !indicator.is_overlay() {
1799                    return;
1800                }
1801                let config = SelectionDotConfig {
1802                    dot_interval: calculate_dot_interval(coords.bar_spacing),
1803                    ..Default::default()
1804                };
1805                // Multi-line indicators highlight every line so the whole study
1806                // reads as selected.
1807                for line_idx in 0..indicator.line_cnt() {
1808                    render_indicator_selection_dots(
1809                        painter,
1810                        indicator.as_ref(),
1811                        line_idx,
1812                        visible_range.clone(),
1813                        coords,
1814                        |price| coords.price_to_y(price),
1815                        &config,
1816                    );
1817                }
1818            }
1819            ChartElementId::Series(series_id) => {
1820                let config = SelectionHandleConfig {
1821                    dot_interval: calculate_dot_interval(coords.bar_spacing),
1822                    ..Default::default()
1823                };
1824                let closes: Vec<f64> = visible_data.iter().map(|b| b.close).collect();
1825                match series_id {
1826                    SeriesId::VOLUME => {
1827                        // Volume handles sit on the volume rect; render on the
1828                        // bar tops at the configured interval.
1829                        let volume_coords = coords.with_rect(self.last_rendered_volume_rect);
1830                        let max_volume = visible_data
1831                            .iter()
1832                            .map(|b| b.volume)
1833                            .fold(0.0_f64, f64::max)
1834                            .max(1.0);
1835                        render_candle_selection_dots(
1836                            painter,
1837                            visible_range,
1838                            &volume_coords,
1839                            &closes,
1840                            |volume| {
1841                                let norm = (volume / max_volume) as f32;
1842                                volume_coords.rect.bottom() - norm * volume_coords.rect.height()
1843                            },
1844                            &config,
1845                        );
1846                    }
1847                    _ => {
1848                        render_candle_selection_dots(
1849                            painter,
1850                            visible_range,
1851                            coords,
1852                            &closes,
1853                            |price| coords.price_to_y(price),
1854                            &config,
1855                        );
1856                    }
1857                }
1858            }
1859            // Pane indicators are handled where their panes are rendered.
1860            ChartElementId::PaneIndicator(_) => {}
1861        }
1862    }
1863}
1864
1865/// Hit-test a click on the main chart area, applying selection priority.
1866///
1867/// Overlay indicator lines take priority over the series beneath them, matching
1868/// the visual stacking order. Returns the hit element and the bar index where
1869/// the hit occurred, or `None` when the click landed on empty chart area.
1870fn hit_test_main_chart(
1871    click_pos: Pos2,
1872    indicators: Option<&IndicatorRegistry>,
1873    coords: &ChartMapping,
1874    volume_coords: Option<&ChartMapping>,
1875    bars: &[crate::model::Bar],
1876    max_volume: f64,
1877    visible_range: std::ops::Range<usize>,
1878) -> Option<(ChartElementId, usize)> {
1879    if bars.is_empty() {
1880        return None;
1881    }
1882
1883    // 1. Overlay indicators (drawn on top of the series).
1884    if let Some(registry) = indicators {
1885        for (idx, indicator) in registry.indicators().iter().enumerate() {
1886            if let Some(hit) = hit_test_indicator(
1887                click_pos,
1888                indicator.as_ref(),
1889                idx,
1890                visible_range.clone(),
1891                coords,
1892                |price| coords.price_to_y(price),
1893            ) {
1894                return Some((
1895                    ChartElementId::OverlayIndicator(hit.indicator_idx),
1896                    hit.bar_idx,
1897                ));
1898            }
1899        }
1900    }
1901
1902    // 2. Main price series (candles/bars/line).
1903    if let Some(hit) = hit_test_candles(
1904        click_pos,
1905        bars,
1906        visible_range.clone(),
1907        coords,
1908        |price| coords.price_to_y(price),
1909        &crate::chart::series::HitTestConfig::default(),
1910    ) {
1911        return Some((ChartElementId::Series(hit.series_id), hit.bar_idx));
1912    }
1913
1914    // 3. Volume series (only when a volume pane was rendered).
1915    if let Some(volume_coords) = volume_coords
1916        && let Some(hit) =
1917            hit_test_volume(click_pos, bars, visible_range, volume_coords, max_volume)
1918    {
1919        return Some((ChartElementId::Series(hit.series_id), hit.bar_idx));
1920    }
1921
1922    None
1923}
1924
1925#[cfg(test)]
1926mod selection_tests {
1927    use super::*;
1928    use crate::model::Bar;
1929    use crate::studies::{CustomIndicator, IndicatorValue};
1930    use chrono::{TimeZone, Utc};
1931    use egui::{Pos2, Rect, Vec2};
1932
1933    /// Build a deterministic mapping plus a small candle series.
1934    ///
1935    /// The series is flat OHLC at the integer prices `[10, 11, 12, 13, 14]` so
1936    /// every candle has a visible body and an exact price-to-screen mapping.
1937    fn fixture() -> (ChartMapping, Vec<Bar>) {
1938        let rect = Rect::from_min_size(Pos2::new(0.0, 0.0), Vec2::new(500.0, 400.0));
1939        let bars: Vec<Bar> = (0..5)
1940            .map(|i| {
1941                let close = 10.0 + i as f64;
1942                Bar::new(
1943                    Utc.timestamp_opt(i as i64, 0).unwrap(),
1944                    close - 0.4,
1945                    close + 0.5,
1946                    close - 0.5,
1947                    close + 0.4,
1948                    100.0,
1949                )
1950            })
1951            .collect();
1952        // base_idx = last bar, no scroll offset, price window covers the data.
1953        let mapping = ChartMapping::new(rect, 40.0, 0, bars.len() - 1, 0.0, 8.0, 16.0);
1954        (mapping, bars)
1955    }
1956
1957    fn overlay_at(values: Vec<IndicatorValue>) -> IndicatorRegistry {
1958        let mut registry = IndicatorRegistry::new();
1959        let captured = values.clone();
1960        registry.add(Box::new(
1961            CustomIndicator::new("TestLine", Box::new(move |_| captured.clone()))
1962                .with_overlay(true),
1963        ));
1964        // Prime the cached values without needing real bar input.
1965        registry.calculate_all(&[]);
1966        registry
1967    }
1968
1969    #[test]
1970    fn click_on_empty_area_returns_none() {
1971        let (mapping, bars) = fixture();
1972        // A point near the top of the chart, above every candle body and on no
1973        // indicator line.
1974        let pos = Pos2::new(mapping.idx_to_x(2), mapping.rect.min.y + 1.0);
1975        let hit = hit_test_main_chart(pos, None, &mapping, None, &bars, 100.0, 0..bars.len());
1976        assert!(hit.is_none());
1977    }
1978
1979    #[test]
1980    fn click_on_candle_selects_main_series() {
1981        let (mapping, bars) = fixture();
1982        // Click the center of bar 2's body (close = 12.4, open = 11.6).
1983        let bar = &bars[2];
1984        let mid_price = (bar.open + bar.close) / 2.0;
1985        let pos = Pos2::new(mapping.idx_to_x(2), mapping.price_to_y(mid_price));
1986        let hit = hit_test_main_chart(pos, None, &mapping, None, &bars, 100.0, 0..bars.len());
1987        assert_eq!(hit, Some((ChartElementId::Series(SeriesId::MAIN), 2)));
1988    }
1989
1990    #[test]
1991    fn overlay_indicator_wins_over_series_at_same_point() {
1992        let (mapping, bars) = fixture();
1993        // Place an indicator value at bar 2 and bar 3 exactly on the candle's
1994        // close price so its line passes through the body. A click there must
1995        // resolve to the overlay, not the series beneath it.
1996        let mut values = vec![IndicatorValue::None; bars.len()];
1997        values[2] = IndicatorValue::Single(bars[2].close);
1998        values[3] = IndicatorValue::Single(bars[3].close);
1999        let registry = overlay_at(values);
2000
2001        let pos = Pos2::new(mapping.idx_to_x(2), mapping.price_to_y(bars[2].close));
2002        let hit = hit_test_main_chart(
2003            pos,
2004            Some(&registry),
2005            &mapping,
2006            None,
2007            &bars,
2008            100.0,
2009            0..bars.len(),
2010        );
2011        assert_eq!(hit, Some((ChartElementId::OverlayIndicator(0), 2)));
2012    }
2013
2014    #[test]
2015    fn hidden_or_pane_indicator_does_not_steal_overlay_priority() {
2016        let (mapping, bars) = fixture();
2017        // A non-overlay (pane) indicator must be ignored by the main-chart hit
2018        // test even though its values sit on the candle body.
2019        let mut values = vec![IndicatorValue::None; bars.len()];
2020        values[2] = IndicatorValue::Single(bars[2].close);
2021        let mut registry = IndicatorRegistry::new();
2022        let captured = values.clone();
2023        registry.add(Box::new(
2024            CustomIndicator::new("Pane", Box::new(move |_| captured.clone())).with_overlay(false),
2025        ));
2026        registry.calculate_all(&[]);
2027
2028        let bar = &bars[2];
2029        let mid_price = (bar.open + bar.close) / 2.0;
2030        let pos = Pos2::new(mapping.idx_to_x(2), mapping.price_to_y(mid_price));
2031        let hit = hit_test_main_chart(
2032            pos,
2033            Some(&registry),
2034            &mapping,
2035            None,
2036            &bars,
2037            100.0,
2038            0..bars.len(),
2039        );
2040        // Falls through to the series, since the pane indicator is not an overlay.
2041        assert_eq!(hit, Some((ChartElementId::Series(SeriesId::MAIN), 2)));
2042    }
2043
2044    #[test]
2045    fn empty_data_returns_none() {
2046        let (mapping, _) = fixture();
2047        let pos = mapping.rect.center();
2048        let hit = hit_test_main_chart(pos, None, &mapping, None, &[], 100.0, 0..0);
2049        assert!(hit.is_none());
2050    }
2051
2052    #[test]
2053    fn take_indicator_remove_drains_once() {
2054        use crate::model::BarData;
2055
2056        let mut chart = Chart::new(BarData::default());
2057        // No request pending after construction.
2058        assert_eq!(chart.take_indicator_remove(), None);
2059
2060        // A pane legend "x" click records the indicator's registry index.
2061        chart.indicator_remove = Some(2);
2062        assert_eq!(chart.take_indicator_remove(), Some(2));
2063        // The request is consumed exactly once, mirroring `take_right_click`.
2064        assert_eq!(chart.take_indicator_remove(), None);
2065    }
2066}