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 ×cale,
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(®istry),
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(®istry),
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}