1use crate::chart::indicators::{
4 SelectionDotConfig, hit_test_pane_indicator, render_indicator_selection_dots,
5};
6use crate::chart::selection::ChartElementId;
7use crate::chart::series::calculate_dot_interval;
8use crate::localization as l10n;
9use crate::studies::{CustomIndicator, Indicator};
10use crate::theme::ThemeExt;
11use crate::tokens::DESIGN_TOKENS;
12use crate::widget::{
13 Chart, IndicatorCoordParams, IndicatorPane, IndicatorPaneConfig, PaneId, PaneInteraction,
14};
15use egui::{Pos2, Rect, Response, Sense, Ui};
16
17#[derive(Clone, Debug)]
26#[non_exhaustive]
27pub struct RenderedIndicatorPane {
28 pub indicator_idx: usize,
32 pub panel_rect: Rect,
34 pub chart_rect: Rect,
36 pub y_min: f64,
38 pub y_max: f64,
40 pub coords: IndicatorCoordParams,
42 pub mapping: crate::chart::coords::ChartMapping,
44}
45
46impl Chart {
47 fn apply_pane_heights(&mut self, expanded: &[usize], total: f32) {
50 let panes = &mut self.pane_layout.panes;
51 let requested: f32 = expanded.iter().filter_map(|idx| panes[*idx].height).sum();
52 if requested <= 0.0 {
53 return;
54 }
55 let others: Vec<_> = expanded
56 .iter()
57 .filter(|idx| panes[**idx].height.is_none())
58 .collect();
59 let shared: f32 = others.iter().map(|idx| panes[**idx].stretch).sum();
60 let scale = if others.is_empty() {
61 1.0
62 } else {
63 shared / (total - requested).max(40.0 * others.len() as f32)
64 };
65 for idx in expanded {
66 if let Some(height) = panes[*idx].height.take() {
67 panes[*idx].stretch = height * scale;
68 }
69 }
70 }
71
72 pub(in crate::widget) fn show_with_panes(
75 &mut self,
76 ui: &mut Ui,
77 footprint: Option<(
78 &crate::data::Footprint,
79 &crate::chart::overlays::FootprintConfig,
80 )>,
81 ) -> Response {
82 let host_spacing = std::mem::take(&mut ui.spacing_mut().item_spacing);
84
85 let available = ui.available_rect_before_wrap();
86 let maximized = self.pane_layout.maximized;
87 let mut panes: Vec<_> = self
90 .pane_layout
91 .panes
92 .iter()
93 .enumerate()
94 .filter(|(_, pane)| {
95 pane.id == PaneId::MAIN
96 || self.pane_shown(pane) && maximized.is_none_or(|id| id == pane.id)
97 })
98 .map(|(idx, pane)| (idx, pane.id, false))
99 .collect();
100 let pane_count = panes.len().saturating_sub(1);
101 let axis_height = self.time_axis_height().min(available.height());
102 let gap = if maximized.is_some() {
103 0.0
104 } else {
105 DESIGN_TOKENS
106 .spacing
107 .hairline
108 .min((available.height() - axis_height).max(0.0) / pane_count.max(1) as f32)
109 };
110 let usable_height = (available.height() - axis_height - gap * pane_count as f32).max(0.0);
111 let auto_collapse = usable_height / (3.0 + pane_count as f32) < 40.0;
112 let row_height = 24.0_f32.min(usable_height / pane_count.max(1) as f32);
113 let mut expanded_budget = ((usable_height - row_height * pane_count as f32 - 40.0)
114 / (40.0 - row_height))
115 .max(0.0) as usize;
116 for (idx, id, collapsed) in &mut panes {
117 if *id == PaneId::MAIN {
118 continue;
119 }
120 *collapsed = maximized.is_none()
121 && (self.pane_layout.panes[*idx]
122 .collapsed
123 .unwrap_or(auto_collapse)
124 || expanded_budget == 0);
125 if !*collapsed {
126 expanded_budget = expanded_budget.saturating_sub(1);
127 }
128 }
129 let collapsed_count = panes.iter().filter(|(_, _, collapsed)| *collapsed).count();
130 let remaining = (usable_height - row_height * collapsed_count as f32).max(0.0);
131 let expanded: Vec<_> = panes
132 .iter()
133 .filter(|(_, _, collapsed)| !collapsed)
134 .map(|(idx, _, _)| *idx)
135 .collect();
136 if maximized.is_none() && remaining > 0.0 {
137 self.apply_pane_heights(&expanded, remaining);
138 }
139 let stretch = |idx: &usize| self.pane_layout.panes[*idx].stretch;
140 let total: f32 = expanded.iter().map(stretch).sum();
141 let pane_total: f32 = expanded
142 .iter()
143 .filter(|idx| self.pane_layout.panes[**idx].id != PaneId::MAIN)
144 .map(stretch)
145 .sum();
146 let mut desired: Vec<_> = panes
147 .iter()
148 .map(|(idx, id, collapsed)| {
149 if *id == PaneId::MAIN || *collapsed {
150 0.0
151 } else {
152 let share = |total| remaining * (stretch(idx) / total);
154 share(total).max(40.0).min(share(pane_total))
155 }
156 })
157 .collect();
158 if maximized.is_some_and(|id| id != PaneId::MAIN) {
159 for (height, (_, id, _)) in desired.iter_mut().zip(&panes) {
160 if *id != PaneId::MAIN {
161 *height = usable_height;
162 }
163 }
164 }
165 let main_min = if maximized.is_some_and(|id| id != PaneId::MAIN) {
166 0.0
167 } else {
168 40.0
169 };
170 let expanded_count = expanded.len() - 1;
171 let pane_budget = (remaining - main_min).max(0.0);
172 let minimum = (pane_budget / expanded_count.max(1) as f32).min(40.0);
173 let excess: f32 = desired
174 .iter()
175 .map(|height| (*height - minimum).max(0.0))
176 .sum();
177 let factor =
178 ((pane_budget - minimum * expanded_count as f32).max(0.0) / excess.max(1.0)).min(1.0);
179 for (height, (_, id, collapsed)) in desired.iter_mut().zip(&panes) {
180 if *id != PaneId::MAIN && !collapsed {
181 *height = minimum + (*height - minimum).max(0.0) * factor;
182 }
183 }
184 let main_height = (remaining - desired.iter().sum::<f32>()).max(0.0);
185 let mut top = available.top();
186 let mut rects = Vec::with_capacity(panes.len());
187 for (at, ((idx, id, collapsed), height)) in panes.iter().zip(&desired).enumerate() {
188 if at > 0 {
189 top += gap;
190 }
191 let height = if *id == PaneId::MAIN {
192 main_height
193 } else if *collapsed {
194 row_height
195 } else {
196 *height
197 };
198 let rect = Rect::from_min_size(
199 Pos2::new(available.left(), top),
200 egui::vec2(available.width(), height),
201 );
202 top += height;
203 self.pane_layout.panes[*idx].rect = rect;
204 rects.push(rect);
205 }
206 let main_at = panes
207 .iter()
208 .position(|(_, id, _)| *id == PaneId::MAIN)
209 .expect("the price pane is always drawn");
210 let main_rect = rects[main_at];
211 let plot = self.pane_layout_rects(main_rect, false).price_rect;
212 let time_axis = Rect::from_min_max(
213 Pos2::new(plot.left(), available.bottom() - axis_height),
214 Pos2::new(plot.right(), available.bottom()),
215 );
216 let maximized_plot = maximized.filter(|id| *id != PaneId::MAIN).map(|id| {
217 ui.interact(
218 Rect::from_min_max(
219 Pos2::new(plot.left(), available.top()),
220 Pos2::new(plot.right(), time_axis.top()),
221 ),
222 ui.scope_id().with((id, "plot")),
223 Sense::click_and_drag(),
224 )
225 });
226 let response = ui
227 .scope_builder(
228 egui::UiBuilder::new()
229 .max_rect(main_rect)
230 .layout(egui::Layout::top_down(egui::Align::LEFT)),
231 |ui| self.show_internal(ui, Some(time_axis), maximized_plot.as_ref(), footprint),
232 )
233 .inner;
234
235 let mut host_clicks = Vec::new();
236 let studies = if self.chart_type.is_time_independent() {
237 &self.element_studies.studies
238 } else {
239 &self.studies
240 };
241 let (start_idx, end_idx) = self.state.visible_range();
243 let visible_range = start_idx..end_idx;
244
245 let time_scale = self.state.time_scale();
247 let mut coords = IndicatorCoordParams::new(
248 time_scale.bar_spacing(),
249 time_scale.right_offset_precise(),
250 self.state.shown().len().saturating_sub(1),
251 start_idx,
252 );
253 coords.numeric_positions = time_scale.numeric_positions().cloned();
254
255 let horizontal = coords.to_mapping(plot, 0.0, 1.0);
256 let displayed_range =
257 horizontal.x_to_idx(plot.left())..horizontal.x_to_idx(plot.right()).saturating_add(1);
258 let hover_rect = Rect::from_min_max(
260 Pos2::new(
261 plot.left(),
262 if main_at == 0 {
263 plot.top()
264 } else {
265 available.top()
266 },
267 ),
268 Pos2::new(plot.right(), time_axis.top()),
269 );
270
271 let mut pending_pane_selection: Option<Option<ChartElementId>> = None;
276 let mut pending_indicator_action = None;
279 let mut pending_series_action = None;
281 let mut pending_pane_collapse = None;
282 let mut pending_pane_maximize = None;
283 let current_selection = self.selection.selected_id();
284 let frame = CustomIndicator::new("", |_| Vec::new())
286 .with_overlay(false)
287 .with_line_count(0);
288
289 for ((pane_idx, pane_id, collapsed), panel_rect) in
290 panes.iter().copied().zip(rects.iter().copied())
291 {
292 if pane_id == PaneId::MAIN {
293 continue;
294 }
295 #[cfg(feature = "drawings")]
296 let eraser_mode = self.drawings.eraser_mode;
297 #[cfg(not(feature = "drawings"))]
298 let eraser_mode = false;
299 #[cfg(feature = "drawings")]
300 let drawing_tool_active = self.drawings.active_tool.is_some();
301 #[cfg(not(feature = "drawings"))]
302 let drawing_tool_active = false;
303
304 let height = panel_rect.height();
305 let members = self.pane_layout.panes[pane_idx].members.clone();
306 let primary = if self.config.show_right_axis {
307 crate::model::SeriesScale::Right
308 } else {
309 crate::model::SeriesScale::Left
310 };
311 let series: Vec<_> = members
312 .iter()
313 .filter_map(|id| {
314 self.price_series
315 .iter()
316 .position(|item| item.id == *id && item.visible)
317 })
318 .collect();
319 let thousands_sep = self.config.thousands_sep.filter(|_| !series.is_empty());
321 let prices: Vec<f64> = series
324 .iter()
325 .flat_map(|at| {
326 let item = &self.price_series[*at];
327 self.series_fit(item)
328 .into_iter()
329 .flat_map(|(min, max)| [min, max])
330 .chain(self.marker_prices(&item.markers, &[], item.marker_options))
331 .chain(self.primitive_prices(item.id))
332 })
333 .filter(|price| price.is_finite())
334 .collect();
335 let mut shared_range = (f64::INFINITY, f64::NEG_INFINITY);
336 let mut primitive_prices = Vec::new();
337 for id in &members {
338 let Some(idx) = self.study_ids.iter().position(|study| study == id) else {
339 continue;
340 };
341 let study = studies.indicators()[idx].as_ref();
342 if study.is_visible()
343 && !matches!(study.scale(), crate::studies::StudyScale::Shared(key) if key != primary)
344 {
345 let range = IndicatorPane::with_config(IndicatorPaneConfig::from_study(study))
346 .calculate_y_range(study, &displayed_range);
347 shared_range.0 = shared_range.0.min(range.0);
348 shared_range.1 = shared_range.1.max(range.1);
349 primitive_prices.extend(self.primitive_prices(*id));
350 }
351 }
352 for price in primitive_prices.iter().filter(|price| price.is_finite()) {
353 shared_range.0 = shared_range.0.min(*price);
354 shared_range.1 = shared_range.1.max(*price);
355 }
356 if let (Some(min), Some(max)) = (
357 prices.iter().copied().reduce(f64::min),
358 prices.iter().copied().reduce(f64::max),
359 ) {
360 let padding = crate::scales::auto_scale_span(min, max) * 0.1;
362 shared_range.0 = shared_range.0.min(min - padding);
363 shared_range.1 = shared_range.1.max(max + padding);
364 }
365 let first_close = series.iter().find_map(|at| {
366 let item = &self.price_series[*at];
367 item.bars[self.series_range(item)]
368 .iter()
369 .find(|bar| !bar.is_whitespace())
370 .map(|bar| bar.close)
371 });
372 let pane_scale = &mut self.pane_layout.panes[pane_idx].scale;
373 let mode = pane_scale.options().mode;
374 let base = members
375 .iter()
376 .find_map(|id| {
377 let idx = self.study_ids.iter().position(|study| study == id)?;
378 let study = studies.indicators()[idx].as_ref();
379 if !study.is_visible() {
380 return None;
381 }
382 (0..study.line_count())
383 .filter(|line| {
384 let plot = study.plot_style(*line);
385 plot.visible
386 && !matches!(
387 plot.plot_type,
388 crate::studies::PlotType::BarColor
389 | crate::studies::PlotType::BackgroundColor
390 )
391 })
392 .find_map(|line| {
393 displayed_range
394 .clone()
395 .find_map(|bar| study.plot_value(line, bar))
396 })
397 })
398 .or(first_close)
399 .filter(|base| *base != 0.0)
400 .unwrap_or(1.0);
401 let transform = crate::scales::PriceTransform { mode, base };
402 pane_scale.rebase(transform);
403 if mode == crate::scales::PriceScaleMode::Logarithmic {
405 shared_range = (f64::INFINITY, f64::NEG_INFINITY);
406 for id in &members {
407 let Some(idx) = self.study_ids.iter().position(|study| study == id) else {
408 continue;
409 };
410 let study = studies.indicators()[idx].as_ref();
411 if study.is_visible()
412 && !matches!(study.scale(), crate::studies::StudyScale::Shared(key) if key != primary)
413 {
414 for line in (0..study.line_count()).filter(|line| {
415 let plot = study.plot_style(*line);
416 plot.visible
417 && !matches!(
418 plot.plot_type,
419 crate::studies::PlotType::BarColor
420 | crate::studies::PlotType::BackgroundColor
421 )
422 }) {
423 for value in displayed_range
424 .clone()
425 .filter_map(|bar| study.plot_value(line, bar))
426 .filter(|value| *value > 0.0)
427 {
428 shared_range.0 = shared_range.0.min(value);
429 shared_range.1 = shared_range.1.max(value);
430 }
431 }
432 }
433 }
434 for value in prices
435 .iter()
436 .chain(&primitive_prices)
437 .filter(|value| **value > 0.0)
438 {
439 shared_range.0 = shared_range.0.min(*value);
440 shared_range.1 = shared_range.1.max(*value);
441 }
442 }
443 pane_scale.autoscale(
444 transform.to_scale(shared_range.0),
445 transform.to_scale(shared_range.1),
446 );
447 let marker_map = pane_scale.map(transform, panel_rect.top(), panel_rect.bottom());
448 let marker_bounds: Vec<_> = series
449 .iter()
450 .flat_map(|at| {
451 self.series_marker_bounds(ui.painter(), self.price_series[*at].id, marker_map)
452 })
453 .collect();
454 let pane_scale = &mut self.pane_layout.panes[pane_idx].scale;
455 Self::fit_marker_bounds(pane_scale, panel_rect, marker_bounds);
456 let map = pane_scale
457 .map(transform, panel_rect.top(), panel_rect.bottom())
458 .with_thousands_sep(thousands_sep);
459 let map = series.first().map_or(map, |at| {
460 self.series_display_map(self.price_series[*at].id, map)
461 });
462 let mut drawing_response = None;
463 let mut axis_tags = Vec::new();
464 let hovered = self.last_hover_bar_idx.and_then(|index| {
465 let index = index.round() as usize;
466 (index < self.state.shown().len()).then_some(index)
467 });
468 let legends: Vec<_> = series
471 .iter()
472 .map(|at| {
473 let item = &self.price_series[*at];
474 let close = match hovered {
475 Some(bar) => self.series_close_at(item, bar),
476 None => crate::model::last_with_value(&item.bars).map(|bar| bar.close),
477 };
478 (
479 item.id,
480 crate::widget::series::SeriesLegend::new(
481 item,
482 close,
483 self.series_label_formatter(
484 item.id,
485 crate::scales::PriceScaleMode::Normal,
486 ),
487 map.price_precision(),
488 map.thousands_sep(),
489 ),
490 )
491 })
492 .collect();
493 let pane_plot = Rect::from_x_y_ranges(plot.x_range(), panel_rect.y_range());
494 let mut pane_mapping = coords.to_mapping(pane_plot, 0.0, 1.0);
495 pane_mapping.price = map;
496 let on_top = ui
497 .input(|input| input.pointer.hover_pos())
498 .filter(|pos| {
499 self.chart_options.hovered_series_on_top
500 && !collapsed
501 && pane_plot.contains(*pos)
502 })
503 .and_then(|pos| {
504 members.iter().rev().find_map(|id| {
505 if let Some(item) = self
506 .price_series
507 .iter()
508 .find(|item| item.id == *id && item.visible)
509 {
510 self.hit_price_series(item, pos, pane_plot, map)
511 .map(|_| *id)
512 } else {
513 let idx = self.study_ids.iter().position(|study| study == id)?;
514 let study = studies.indicators()[idx].as_ref();
515 let hit = crate::widget::primitives::primitive_at(
516 self.primitives_of(*id).map(|item| {
517 (
518 item,
519 self.primitive_view(
520 *id,
521 if matches!(
522 study.scale(),
523 crate::studies::StudyScale::Shared(_)
524 ) {
525 crate::chart::rendering::study_mapping(
526 study,
527 &pane_mapping,
528 )
529 } else {
530 pane_mapping.clone()
531 },
532 ),
533 )
534 }),
535 pos,
536 )
537 .is_some();
538 (hit || (!self.has_renderer(*id)
539 && hit_test_pane_indicator(
540 pos,
541 study,
542 idx,
543 visible_range.clone(),
544 pane_plot,
545 &pane_mapping,
546 )
547 .is_some()))
548 .then_some(*id)
549 }
550 })
551 });
552 let mut entries: Vec<_> = members
555 .iter()
556 .filter_map(
557 |id| match self.study_ids.iter().position(|study| study == id) {
558 Some(idx) => studies.indicators()[idx]
559 .is_visible()
560 .then_some((*id, Some(idx))),
561 None => legends
562 .iter()
563 .any(|(series, _)| series == id)
564 .then_some((*id, None)),
565 },
566 )
567 .map(Some)
568 .collect();
569 if entries.is_empty() {
570 entries.push(None);
571 }
572 let mut series_crosshair = None;
574 let first = entries
575 .iter()
576 .min_by_key(|entry| entry.is_some_and(|(id, _)| Some(id) == on_top))
577 .copied();
578 let mut panels = Vec::with_capacity(entries.len());
579 let mut legend_offset = 0.0;
580 for entry in entries {
581 let id = entry.map(|(id, _)| id);
582 let study = entry.and_then(|(_, idx)| idx);
583 let indicator: &dyn Indicator = match (study, id) {
584 (Some(idx), _) => studies.indicators()[idx].as_ref(),
585 (None, Some(id)) => legends
586 .iter()
587 .find(|(series, _)| *series == id)
588 .map(|(_, legend)| legend)
589 .expect("a series entry has a legend"),
590 (None, None) => &frame,
591 };
592 let mut config = IndicatorPaneConfig::from_study(indicator);
593 config.show_name = self.config.show_indicator_labels;
594 config.show_values = self.config.show_indicator_last_val;
595 config.inputs = match study {
596 Some(idx) if self.config.show_indicator_inputs => {
597 self.study_inputs[idx].clone()
598 }
599 _ => String::new(),
600 };
601 if self.config.show_indicator_inputs
602 && indicator.is_volume()
603 && indicator.plot_style(2).visible
604 && config.inputs.is_empty()
605 {
606 config.inputs = indicator.line_names().get(2).cloned().unwrap_or_default();
607 }
608 config.show_legend = entry.is_some()
609 && (config.show_name || config.show_values || !config.inputs.is_empty());
610 config.height = height;
611 config.show_grid &= Some(entry) == first;
612 let mut panel = IndicatorPane::with_config(config);
613 panel.legend_backdrop = self.config.indicator_backdrop_opacity;
614 panel.axis_options = crate::config::PriceAxisOptions {
615 show_study_values: self.config.show_indicator_last_val
616 && self.config.price_axis.show_study_values,
617 ..self.config.price_axis
618 };
619 panel.price_map = Some(
620 if matches!(indicator.scale(), crate::studies::StudyScale::Shared(_)) {
621 crate::chart::rendering::study_mapping(indicator, &pane_mapping).price
622 } else {
623 map
624 },
625 );
626 if study.is_none() && id.is_some() {
627 panel.price_formatter =
628 id.and_then(|id| self.series_label_formatter(id, map.transform().mode));
629 }
630 if let crate::studies::StudyScale::Shared(key) = indicator.scale()
631 && let Some((_, axis)) = self
632 .secondary_axes(available, plot)
633 .into_iter()
634 .find(|(scale, _)| *scale == key)
635 {
636 panel.axis_rect =
637 Some(Rect::from_x_y_ranges(axis.x_range(), panel_rect.y_range()));
638 }
639 panel.legend_offset = legend_offset;
640 panel.pane_controls = true;
641 panel.bar_index = hovered.or(id.filter(|_| study.is_none()).map(|_| 0));
643 let legend_row_height = panel
644 .legend_galley(
645 ui.painter(),
646 pane_plot,
647 indicator,
648 ui.active_theme().ui.text,
649 )
650 .size()
651 .y
652 + ui.active_theme().spacing.md;
653 legend_offset += legend_row_height;
654 panels.push((entry, indicator, panel, legend_row_height));
655 }
656 panels.sort_by_key(|(entry, _, _, _)| entry.is_some_and(|(id, _)| Some(id) == on_top));
657 for (paint_row, (entry, indicator, mut panel, legend_row_height)) in
658 panels.into_iter().enumerate()
659 {
660 let id = entry.map(|(id, _)| id);
661 let study = entry.and_then(|(_, idx)| idx);
662 let key = (pane_id, id);
663 panel.overlay = paint_row > 0;
664 panel.axis_tags = axis_tags.clone();
665 let mut hover = self
666 .axis_hover
667 .map(|(pos, _)| pos)
668 .or_else(|| {
669 ui.input(|input| input.pointer.hover_pos()).filter(|pos| {
670 hover_rect.contains(*pos) && ui.rect_contains_pointer(hover_rect)
671 })
672 })
673 .or_else(|| {
674 self.synced_crosshair_bar_idx
675 .filter(|index| index.is_finite() && *index >= 0.0)
676 .map(|index| {
677 let index =
678 if self.chart_options.time_scale.ignore_whitespace_indices
679 || self.chart_options.time_scale.ignore_hidden_indices
680 {
681 index
682 } else {
683 index.round()
684 };
685 Pos2::new(pane_mapping.index_to_x(index), plot.center().y)
686 })
687 .filter(|pos| plot.contains(*pos))
688 })
689 .filter(|_| !self.menu_open && !egui::Popup::is_any_open(ui.ctx()))
690 .and_then(|pos| self.crosshair_position(pos, &pane_mapping));
691 let mut crosshair_options = self.chart_options.crosshair.resolved(ui.ctx());
692 if eraser_mode && crosshair_options.style != crate::config::CrosshairStyle::Hidden {
693 crosshair_options.style = crate::config::CrosshairStyle::Arrow;
694 }
695
696 if panel.overlay {
697 hover = None;
698 } else if crosshair_options.mode.snaps() {
699 if let Some(pos) = &mut hover
700 && panel_rect.contains(*pos)
701 && let Some(bar) = panel.bar_index
702 && let Some(value) = crate::chart::rendering::snap_to_values(
703 pos.y,
704 &map,
705 self.pane_layout.panes[pane_idx]
706 .members
707 .iter()
708 .filter_map(|id| {
709 self.study_ids.iter().position(|study| study == id)
710 })
711 .map(|idx| studies.indicators()[idx].as_ref())
712 .filter(|study| study.is_visible())
713 .flat_map(|study| {
714 (0..study.line_count())
715 .filter(|line| {
716 let plot = study.plot_style(*line);
717 plot.visible
718 && !matches!(
719 plot.plot_type,
720 crate::studies::PlotType::BarColor
721 | crate::studies::PlotType::BackgroundColor
722 )
723 })
724 .filter_map(move |line| study.plot_value(line, bar))
725 })
726 .chain(series.iter().filter_map(|at| {
727 self.series_close_at(&self.price_series[*at], bar)
728 })),
729 )
730 {
731 pos.y = map.price_to_y(value);
732 }
733 crosshair_options.mode = crate::config::CrosshairMode::Normal;
734 }
735 if !series.is_empty()
736 && !collapsed
737 && let Some(pos) = hover
738 {
739 series_crosshair = Some((pos, crosshair_options));
741 crosshair_options.vert_line_color = egui::Color32::TRANSPARENT;
742 crosshair_options.horz_line_color = egui::Color32::TRANSPARENT;
743 }
744
745 let mut child =
747 ui.new_child(egui::UiBuilder::new().id_salt(key).max_rect(panel_rect));
748 if let Some(interaction) = panel.show_aligned_interactive_in(
749 &mut child,
750 indicator,
751 self.state.shown(),
752 coords.clone(),
753 Some(plot.x_range()),
754 collapsed,
755 if matches!(
756 indicator.scale(),
757 crate::studies::StudyScale::Shared(crate::model::SeriesScale::Overlay(_))
758 ) {
759 None
760 } else if matches!(
761 indicator.scale(),
762 crate::studies::StudyScale::Shared(
763 crate::model::SeriesScale::Left
764 | crate::model::SeriesScale::Separate(_)
765 )
766 ) {
767 Some(crate::scales::PriceScaleId::Left)
768 } else if self.config.show_right_axis {
769 Some(crate::scales::PriceScaleId::Right)
770 } else if self.config.show_left_axis {
771 Some(crate::scales::PriceScaleId::Left)
772 } else {
773 None
774 },
775 hover.map(|pos| (pos, crosshair_options)),
776 &id.into_iter()
777 .flat_map(|id| self.primitives_of(id))
778 .collect::<Vec<_>>(),
779 study.is_none() && id.is_some(),
780 self.merge_groups.factor(),
781 ) {
782 if !collapsed
783 && let Some(item) =
784 id.and_then(|id| self.price_series.iter().find(|item| item.id == id))
785 {
786 let painter = ui
787 .painter()
788 .with_clip_rect(interaction.chart_rect.intersect(ui.clip_rect()));
789 self.paint_series(&painter, interaction.chart_rect, item, map);
790 let view = self.primitive_view(item.id, pane_mapping.clone());
791 for primitive in self.primitives_of(item.id) {
792 primitive.draw(
793 &painter,
794 &view,
795 crate::widget::PrimitivePass::Series,
796 self.merge_groups.factor(),
797 );
798 }
799 }
800 self.measured_axis_width =
801 self.measured_axis_width.max(panel.measured_axis_width);
802 axis_tags = panel.axis_tags.clone();
803 let PaneInteraction {
804 panel_rect,
805 chart_rect,
806 y_min,
807 y_max,
808 response: pane_response,
809 close_response,
810 visibility_response,
811 settings_response,
812 menu,
813 } = interaction;
814
815 let pane_response = match &maximized_plot {
816 Some(plot) => pane_response.union(plot.clone()),
817 None => pane_response.interact(Sense::click_and_drag()),
818 };
819
820 let close_clicked = close_response.as_ref().is_some_and(|r| r.clicked());
822 let visibility_clicked =
823 visibility_response.as_ref().is_some_and(|r| r.clicked());
824 let settings_clicked = settings_response.as_ref().is_some_and(|r| r.clicked());
825 let control_clicked =
826 close_clicked || visibility_clicked || settings_clicked || menu.is_some();
827 use crate::chart::rendering::StudyAction;
828 match (study, id) {
829 (Some(idx), _) if close_clicked => {
830 pending_indicator_action = Some(StudyAction::Remove(idx));
831 }
832 (Some(idx), _) if visibility_clicked => {
833 pending_indicator_action = Some(StudyAction::ToggleVisibility(idx));
834 }
835 (Some(idx), _) if settings_clicked => {
836 pending_indicator_action = Some(StudyAction::Settings(idx));
837 }
838 (Some(idx), _) if menu.is_some() => {
839 pending_indicator_action = Some(StudyAction::Menu {
840 index: idx,
841 pos: menu.unwrap(),
842 });
843 }
844 (None, Some(id)) if control_clicked => {
845 pending_series_action = Some((id, close_clicked, visibility_clicked));
846 }
847 _ => {}
848 }
849 let header = Rect::from_min_max(
850 chart_rect.min + egui::vec2(0.0, panel.legend_offset),
851 Pos2::new(
852 visibility_response
853 .as_ref()
854 .map_or(chart_rect.right() - 42.0, |r| {
855 r.rect.left().min(chart_rect.right())
856 }),
857 (chart_rect.top() + panel.legend_offset + legend_row_height)
858 .min(chart_rect.bottom()),
859 ),
860 );
861 let toggle = ui
862 .interact(header, ui.scope_id().with((key, "toggle")), Sense::click())
863 .on_hover_text(l10n::text(
864 ui.ctx(),
865 if collapsed {
866 "Expand pane"
867 } else {
868 "Collapse pane"
869 },
870 ));
871 let translated_label = l10n::text(
872 ui.ctx(),
873 if collapsed {
874 "Expand pane"
875 } else {
876 "Collapse pane"
877 },
878 );
879 toggle.widget_info(|| {
880 egui::WidgetInfo::labeled(
881 egui::Role::Button,
882 toggle.enabled(),
883 &translated_label,
884 )
885 });
886 ui.ctx()
887 .accesskit_node_builder(toggle.id, |node| node.set_expanded(!collapsed));
888 if toggle.clicked() && !control_clicked {
889 pending_pane_collapse = Some((pane_id, !collapsed));
890 ui.ctx().request_repaint();
891 }
892 let mut pane_coords = coords.to_mapping(chart_rect, y_min, y_max);
893 pane_coords.price = map;
894 let pointer = ui
897 .input(|input| input.pointer.interact_pos())
898 .filter(|pos| chart_rect.contains(*pos));
899 let pane_primitives = || {
900 self.primitives
901 .iter()
902 .filter(|item| {
903 self.pane_layout.panes[pane_idx]
904 .members
905 .contains(&item.series)
906 && self.is_series_visible(item.series)
907 })
908 .map(|item| {
909 let mut view =
910 self.primitive_view(item.series, pane_coords.clone());
911 if let Some(study) = view.study
912 && matches!(
913 study.scale(),
914 crate::studies::StudyScale::Shared(_)
915 )
916 {
917 view.mapping =
918 crate::chart::rendering::study_mapping(study, &pane_coords);
919 }
920 (item, view)
921 })
922 };
923 let primitive_hit = pointer.and_then(|pos| {
924 crate::widget::primitives::primitive_at(
925 pane_primitives().filter(|(item, _)| !item.replaces),
926 pos,
927 )
928 });
929
930 let study_at = |pos| {
935 self.pane_layout.panes[pane_idx]
936 .members
937 .iter()
938 .rev()
939 .find_map(|id| {
940 if self.has_renderer(*id) {
941 let hit = crate::widget::primitives::primitive_at(
942 pane_primitives().filter(|(item, _)| {
943 item.replaces && item.series == *id
944 }),
945 pos,
946 )?;
947 return match hit.target {
948 crate::widget::PointerTarget::Element(id) => Some(id),
949 _ => None,
950 };
951 }
952
953 if let Some(item) = self
954 .price_series
955 .iter()
956 .find(|item| item.id == *id && item.visible)
957 {
958 return self
959 .hit_price_series(item, pos, chart_rect, map)
960 .map(|_| ChartElementId::Series(*id));
961 }
962 let idx = self.study_ids.iter().position(|study| study == id)?;
963 hit_test_pane_indicator(
964 pos,
965 studies.indicators()[idx].as_ref(),
966 idx,
967 visible_range.clone(),
968 chart_rect,
969 &pane_coords,
970 )
971 .map(|hit| {
972 ChartElementId::Indicator(self.study_ids[hit.indicator_idx])
973 })
974 })
975 };
976 if !collapsed
977 && !toggle.clicked()
978 && !control_clicked
979 && pane_response.clicked()
980 && !drawing_tool_active
981 && !eraser_mode
982 && let Some(click_pos) = pane_response.interact_pointer_pos()
983 && self.mark_at(click_pos).is_none()
984 {
985 let hit = study_at(click_pos);
986 if hit.is_some() || pending_pane_selection.is_none() {
987 pending_pane_selection = Some(hit);
988 }
989 }
990 if !collapsed && let Some(pos) = pointer {
992 #[cfg(feature = "drawings")]
993 let drawing = self.drawings.hit_test(pos);
994 #[cfg(not(feature = "drawings"))]
995 let drawing: Option<usize> = None;
996 let renderer = study_at(pos).and_then(|element| {
997 let ChartElementId::Indicator(id) = element else {
998 return None;
999 };
1000 crate::widget::primitives::primitive_at(
1001 pane_primitives()
1002 .filter(|(item, _)| item.replaces && item.series == id),
1003 pos,
1004 )
1005 });
1006 if let Some(hit) = primitive_hit.or(renderer).filter(|_| {
1007 drawing.is_none()
1008 && pane_response.hovered()
1009 && !pane_response.dragged()
1010 && !eraser_mode
1011 && !drawing_tool_active
1012 }) {
1013 ui.ctx().set_cursor_icon(hit.cursor);
1014 }
1015 self.host_events.target = drawing
1016 .map(crate::widget::PointerTarget::Drawing)
1017 .or(primitive_hit.map(|hit| hit.target))
1018 .or_else(|| study_at(pos).map(crate::widget::PointerTarget::Element));
1019 }
1020
1021 if !collapsed
1023 && let Some(id) = id
1024 && current_selection == Some(ChartElementId::Indicator(id))
1025 {
1026 let dot_config = SelectionDotConfig {
1027 dot_interval: calculate_dot_interval(pane_coords.bar_spacing),
1028 ..Default::default()
1029 };
1030 for line_idx in 0..indicator.line_count() {
1031 render_indicator_selection_dots(
1032 &ui.painter()
1033 .with_clip_rect(chart_rect.intersect(ui.clip_rect())),
1034 indicator,
1035 line_idx,
1036 visible_range.clone(),
1037 &pane_coords,
1038 |value| pane_coords.price_to_y(value),
1039 &dot_config,
1040 );
1041 }
1042 }
1043
1044 drawing_response = Some((pane_response, chart_rect, pane_coords.clone()));
1045 if let Some(idx) = study {
1047 self.last_rendered_indicator_panes
1048 .push(RenderedIndicatorPane {
1049 indicator_idx: idx,
1050 panel_rect,
1051 chart_rect,
1052 y_min,
1053 y_max,
1054 coords: coords.clone(),
1055 mapping: pane_coords,
1056 });
1057 }
1058 }
1059 }
1060 if let Some((_, chart_rect, _)) = &drawing_response {
1061 let chart_rect = *chart_rect;
1062 let painter = ui
1063 .painter()
1064 .with_clip_rect(panel_rect.intersect(ui.clip_rect()));
1065 let items: Vec<_> = series.iter().map(|at| &self.price_series[*at]).collect();
1066 let axis_edge = if self.config.show_right_axis {
1067 Some(panel_rect.right())
1068 } else if self.config.show_left_axis {
1069 Some(chart_rect.left())
1070 } else {
1071 None
1072 };
1073 if !collapsed && let Some((mut pos, options)) = series_crosshair {
1074 if let Some(index) = hovered {
1075 pos.x = horizontal.idx_to_x(index);
1076 }
1077 crate::chart::rendering::render_crosshair_lines(
1078 &crate::chart::rendering::RenderContext::new(&painter, chart_rect),
1079 pos,
1080 &options,
1081 chart_rect.bottom(),
1082 );
1083 }
1084 if !collapsed && let Some(axis_edge) = axis_edge {
1085 #[cfg(feature = "drawings")]
1086 for (value, fill) in self
1087 .drawings
1088 .drawings
1089 .iter()
1090 .filter(|drawing| {
1091 drawing.is_visible_on_timeframe(&self.drawings.curr_timeframe)
1092 && (drawing.pane == Some(pane_id.0)
1093 || members.iter().any(|id| drawing.pane == Some(id.0)))
1094 })
1095 .flat_map(|drawing| {
1096 drawing.position_price_tags(ui.active_theme().chart.axis_text_secondary)
1097 })
1098 .filter(|(value, _)| map.range().contains(map.transform().to_scale(*value)))
1099 {
1100 let label = map.label(
1101 map.transform().to_scale(value),
1102 crate::chart::rendering::axes::tag_decimals(
1103 &map,
1104 map.price_to_y(value),
1105 ),
1106 self.price_formatter.as_deref(),
1107 );
1108 axis_tags.push(crate::chart::rendering::axes::render_axis_value(
1109 &painter,
1110 chart_rect,
1111 axis_edge,
1112 map.price_to_y(value),
1113 label,
1114 (crate::chart::rendering::axes::tag_text_color(fill), fill),
1115 &axis_tags,
1116 self.config.price_axis.allow_label_overlap,
1117 ));
1118 }
1119 for (item, (value, colors, last)) in items
1120 .iter()
1121 .flat_map(|item| {
1122 self.series_axis_values(item)
1123 .map(move |value| (item, value))
1124 })
1125 .filter(|(_, (value, ..))| {
1126 map.range().contains(map.transform().to_scale(*value))
1127 })
1128 {
1129 let tag = crate::chart::rendering::axes::render_axis_value(
1130 &painter,
1131 chart_rect,
1132 axis_edge,
1133 map.price_to_y(value),
1134 self.series_tag_label(item, &map, value, last),
1135 colors,
1136 &axis_tags,
1137 self.config.price_axis.allow_label_overlap,
1138 );
1139 axis_tags.push(tag);
1140 }
1141 }
1142 }
1143 if !collapsed && let Some((pane_response, chart_rect, pane_coords)) = drawing_response {
1144 if pane_response.clicked() || pane_response.secondary_clicked() {
1145 host_clicks.push(pane_response.clone());
1146 }
1147 #[cfg(feature = "drawings")]
1148 let drawing_hit = {
1149 let history = self.history.start_drawing_action(&mut self.drawings);
1150 let mut drawings = std::mem::take(&mut self.drawings);
1151 let mut cursors = std::mem::take(&mut self.cursor_modes);
1152 let timescale = self.state.time_scale().clone();
1153 let painter = ui.painter().clone();
1154 let time_formatter = self.build_time_formatter();
1155 self.handle_pane_drawings(
1156 ui,
1157 &mut drawings,
1158 &mut cursors,
1159 &pane_response,
1160 chart_rect,
1161 &map,
1162 &painter,
1163 ×cale,
1164 (time_formatter.as_ref(), self.time_label_seconds()),
1165 Some(pane_id),
1166 );
1167 let drawing_hit = pane_response
1168 .interact_pointer_pos()
1169 .and_then(|pos| drawings.hit_test(pos));
1170 crate::widget::tool_interaction::render_pane_eraser_highlight(
1171 &ui.painter().with_clip_rect(chart_rect),
1172 &drawings,
1173 &cursors,
1174 );
1175 self.drawings = drawings;
1176 self.cursor_modes = cursors;
1177 self.history
1178 .finish_drawing_action(&mut self.drawings, history);
1179 drawing_hit
1180 };
1181 #[cfg(not(feature = "drawings"))]
1182 let drawing_hit: Option<usize> = None;
1183 if pane_response.double_clicked() && drawing_hit.is_none() {
1185 pending_pane_maximize = Some(pane_id);
1186 }
1187 if pane_response.clicked() && drawing_hit.is_some() {
1188 pending_pane_selection = None;
1189 self.selection.deselect();
1190 }
1191 if pane_response.secondary_clicked()
1192 && let Some(pos) = pane_response.interact_pointer_pos()
1193 {
1194 self.right_click = Some(if let Some(id) = drawing_hit {
1195 #[cfg(feature = "drawings")]
1196 {
1197 if self.drawings.is_selected(id) {
1198 let ids: Vec<_> = std::iter::once(id)
1199 .chain(self.drawings.selected_ids())
1200 .collect();
1201 self.drawings.select_drawings(ids);
1202 } else {
1203 self.drawings.select(id);
1204 }
1205 }
1206 crate::widget::RightClickTarget::Drawing {
1207 drawing_id: id,
1208 pos,
1209 }
1210 } else {
1211 crate::widget::RightClickTarget::Background {
1212 pos,
1213 price: map.y_to_price(pos.y),
1214 }
1215 });
1216 }
1217 let painter = ui.painter().with_clip_rect(chart_rect);
1219 for item in self.primitives.iter().filter(|item| {
1220 self.pane_layout.panes[pane_idx]
1221 .members
1222 .contains(&item.series)
1223 && self.is_series_visible(item.series)
1224 }) {
1225 let view = self.primitive_view(item.series, pane_coords.clone());
1226 item.draw(
1227 &painter,
1228 &view,
1229 crate::widget::PrimitivePass::Above,
1230 self.merge_groups.factor(),
1231 );
1232 }
1233 }
1234 if !collapsed {
1235 let mut table_rect = plot;
1236 for id in &members {
1237 if let Some(idx) = self.study_ids.iter().position(|study| study == id) {
1238 let study = &studies.indicators()[idx];
1239 if study.is_visible()
1240 && let Some(table) = study.table()
1241 {
1242 table_rect.max.y = crate::studies::table::paint(
1243 ui,
1244 table_rect,
1245 table,
1246 ui.scope_id().with((pane_id, id)),
1247 )
1248 .top()
1249 - 8.0;
1250 }
1251 }
1252 }
1253 }
1254 }
1255 for response in host_clicks {
1256 self.record_host_click(ui, &response);
1257 }
1258 let drawn: Vec<_> = panes
1259 .iter()
1260 .zip(&rects)
1261 .map(|((idx, _, collapsed), rect)| (*idx, *rect, *collapsed))
1262 .collect();
1263 self.pane_separators(ui, &drawn);
1264 for ((_, id, collapsed), rect) in panes.iter().copied().zip(rects) {
1265 if id == PaneId::MAIN {
1266 continue;
1267 }
1268 if let Some(idx) = self.pane_layout.panes.iter().position(|pane| pane.id == id) {
1269 self.pane_scale_interaction(ui, idx, rect, plot);
1270 self.pane_actions(ui, idx, id, rect, plot, collapsed);
1271 }
1272 }
1273
1274 if let Some(decision) = pending_pane_selection {
1277 match decision {
1278 Some(id) => self.selection.select(id, None),
1279 None => self.selection.deselect(),
1280 }
1281 }
1282
1283 if let Some(id) = pending_pane_maximize {
1285 self.user_action(|chart| {
1286 let maximized = chart.pane_layout.maximized == Some(id);
1287 chart.pane_layout.maximized = (!maximized).then_some(id);
1288 });
1289 }
1290 if let Some((id, collapsed)) = pending_pane_collapse {
1291 self.user_action(|chart| {
1292 if let Some(pane) = chart
1293 .pane_layout
1294 .panes
1295 .iter_mut()
1296 .find(|pane| pane.id == id)
1297 {
1298 pane.collapsed = Some(collapsed);
1299 }
1300 });
1301 }
1302 if let Some(action) = pending_indicator_action {
1303 self.apply_study_action(action);
1304 }
1305 match pending_series_action {
1306 Some((id, true, _)) => {
1307 self.user_action(|chart| chart.remove_series(id));
1308 }
1309 Some((id, _, true)) => {
1310 self.user_action(|chart| chart.set_series_visible(id, false));
1311 }
1312 Some((id, ..)) => self.indicator_settings_request = Some(id),
1313 None => {}
1314 }
1315
1316 ui.advance_cursor_after_rect(available);
1317 ui.spacing_mut().item_spacing = host_spacing;
1318 response
1319 }
1320}
1321
1322#[cfg(all(test, feature = "studies"))]
1323mod tests;