Skip to main content

egui_charts/widget/pane/
mod.rs

1//! Pane placement, layout state and interaction controls.
2
3mod controls;
4mod indicator;
5mod render;
6pub use indicator::{IndicatorCoordParams, IndicatorPane, IndicatorPaneConfig, PaneInteraction};
7pub use render::RenderedIndicatorPane;
8
9use super::volume;
10use super::{Chart, SeriesId};
11#[cfg(feature = "drawings")]
12use crate::drawings::persistence::StoredPane;
13use egui::{Color32, Rect, Vec2};
14
15/// A study's name and input values, which identify it across charts.
16type PaneStudy = (String, Vec<(String, serde_json::Value)>);
17
18/// Identifies a pane of a chart. [`PaneId::MAIN`] is the price pane; [`Chart::add_pane`] and
19/// [`Chart::panes`] return the others. An id stays valid until its pane is removed and is never
20/// reused by the chart.
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
22pub struct PaneId(pub(super) usize);
23
24impl PaneId {
25    /// The price pane, which draws the main series and its overlays.
26    pub const MAIN: PaneId = PaneId(0);
27}
28
29/// Behaviour and colours of the separators between panes, set with
30/// [`Chart::with_pane_options`] or [`Chart::pane_options_mut`].
31#[derive(Clone, Copy, Debug, PartialEq)]
32pub struct PaneOptions {
33    /// Dragging a separator resizes the panes on either side of it, and a double click returns
34    /// both to their default stretch factors. `true` by default.
35    pub resizable: bool,
36    /// Separator colour; `None` uses the theme's grid line colour.
37    pub separator_color: Option<Color32>,
38    /// Colour of a separator under the pointer or being dragged; `None` uses the selection
39    /// colour. Unused when the panes are not resizable.
40    pub separator_hover_color: Option<Color32>,
41}
42
43impl Default for PaneOptions {
44    fn default() -> Self {
45        Self {
46            resizable: true,
47            separator_color: None,
48            separator_hover_color: None,
49        }
50    }
51}
52
53#[derive(Clone)]
54pub(super) struct PaneLayout {
55    /// Every pane from top to bottom, the price pane included.
56    pub(super) panes: Vec<Pane>,
57    pub(super) maximized: Option<PaneId>,
58    /// Overlay draw order from back to front, with the main series as the divider.
59    pub(super) main: Vec<SeriesId>,
60}
61
62impl Default for PaneLayout {
63    fn default() -> Self {
64        Self {
65            panes: vec![Pane::new(PaneId::MAIN)],
66            maximized: None,
67            main: vec![SeriesId::MAIN],
68        }
69    }
70}
71
72/// A change of a study's place in the draw order of its pane, where each item is drawn over the
73/// ones before it. In the main pane the price series is one of the items.
74#[derive(Clone, Copy, Debug, PartialEq, Eq)]
75pub enum VisualOrder {
76    /// Draw over every other item of the pane.
77    BringToFront,
78    /// Swap places with the item drawn just over it.
79    BringForward,
80    /// Swap places with the item drawn just under it.
81    SendBackward,
82    /// Draw under every other item of the pane.
83    SendToBack,
84}
85
86#[derive(Clone)]
87pub(super) struct Pane {
88    pub(super) id: PaneId,
89    /// Studies and host series drawn in the pane, in legend order; empty for the price pane.
90    pub(super) members: Vec<SeriesId>,
91    /// Share of the expanded panes' height, relative to the factors of the others.
92    pub(super) stretch: f32,
93    /// Height to reach on the next layout, which turns it into a stretch factor.
94    pub(super) height: Option<f32>,
95    /// Keep the pane when its last member leaves it.
96    pub(super) preserve: bool,
97    pub(super) collapsed: Option<bool>,
98    pub(super) scale: crate::scales::PriceScale,
99    /// Panel rect in the last frame; [`Rect::NOTHING`] when the pane was not drawn.
100    pub(super) rect: Rect,
101}
102
103impl Pane {
104    fn new(id: PaneId) -> Self {
105        Self {
106            id,
107            members: Vec::new(),
108            stretch: Self::default_stretch(id),
109            height: None,
110            preserve: false,
111            collapsed: None,
112            scale: crate::scales::PriceScale::new(crate::scales::PriceScaleOptions {
113                scale_margins: crate::scales::PriceScaleMargins {
114                    top: 0.0,
115                    bottom: 0.0,
116                },
117                ..Default::default()
118            }),
119            rect: Rect::NOTHING,
120        }
121    }
122
123    /// The price mapping of the pane's scale over its panel rect in the last frame.
124    pub(super) fn price_map(&self) -> crate::scales::PriceMap {
125        self.scale
126            .map(self.scale.transform(), self.rect.top(), self.rect.bottom())
127    }
128
129    /// The price pane takes three shares of the height and every other pane one.
130    pub(super) fn default_stretch(id: PaneId) -> f32 {
131        if id == PaneId::MAIN { 3.0 } else { 1.0 }
132    }
133}
134
135impl Chart {
136    /// Whether `id` is drawn below the price pane when placed there: a host series or a study
137    /// that is not an overlay.
138    fn pane_member(&self, id: SeriesId) -> bool {
139        self.price_series.iter().any(|item| item.id == id)
140            || self
141                .study_ids
142                .iter()
143                .position(|study| *study == id)
144                .is_some_and(|idx| !self.studies.indicators()[idx].is_overlay())
145    }
146
147    /// Whether the host series `id` is drawn by a pane below the price pane.
148    pub(super) fn in_pane(&self, id: SeriesId) -> bool {
149        self.pane_layout
150            .panes
151            .iter()
152            .any(|pane| pane.members.contains(&id))
153    }
154
155    /// Whether `pane` stays in the layout at the next sync, which drops a pane whose members
156    /// have all left it unless it is preserved.
157    fn pane_listed(&self, pane: &Pane) -> bool {
158        pane.preserve
159            || pane.members.is_empty()
160            || pane.members.iter().any(|id| self.pane_member(*id))
161    }
162
163    /// Whether `pane` is drawn below or above the price pane: it is empty or has a visible member.
164    pub(super) fn pane_shown(&self, pane: &Pane) -> bool {
165        pane.id != PaneId::MAIN
166            && self.pane_listed(pane)
167            && (pane.members.is_empty()
168                || pane
169                    .members
170                    .iter()
171                    .any(|id| self.pane_member(*id) && self.is_series_visible(*id)))
172    }
173
174    /// Whether the chart draws any pane besides the price pane.
175    pub(super) fn has_panes(&self) -> bool {
176        self.pane_layout
177            .panes
178            .iter()
179            .any(|pane| self.pane_shown(pane))
180    }
181
182    fn pane_index(&self, pane: PaneId) -> Option<usize> {
183        self.pane_layout
184            .panes
185            .iter()
186            .position(|item| item.id == pane && self.pane_listed(item))
187    }
188
189    /// The legend name of a study or host series.
190    fn member_name(&self, id: SeriesId) -> &str {
191        if let Some(idx) = self.study_ids.iter().position(|study| *study == id) {
192            self.studies.indicators()[idx].name()
193        } else {
194            self.price_series
195                .iter()
196                .find(|item| item.id == id)
197                .map_or("Series", |item| item.options.name.as_str())
198        }
199    }
200
201    /// Removes `id` from its pane and drops that pane if it became empty and is not preserved.
202    fn leave_panes(&mut self, id: SeriesId) {
203        self.pane_layout.panes.retain_mut(|pane| {
204            let before = pane.members.len();
205            pane.members.retain(|member| *member != id);
206            pane.members.len() == before || !pane.members.is_empty() || pane.preserve
207        });
208    }
209
210    pub(super) fn sync_panes(&mut self) {
211        let mut emptied = Vec::new();
212        for at in 0..self.pane_layout.panes.len() {
213            let mut members = std::mem::take(&mut self.pane_layout.panes[at].members);
214            let before = members.len();
215            members.retain(|id| self.pane_member(*id));
216            let pane = &mut self.pane_layout.panes[at];
217            if before > 0 && members.is_empty() && !pane.preserve {
218                emptied.push(pane.id);
219            }
220            pane.members = members;
221        }
222        self.pane_layout
223            .panes
224            .retain(|pane| !emptied.contains(&pane.id));
225        for idx in 0..self.study_ids.len() {
226            let id = self.study_ids[idx];
227            if !self.studies.indicators()[idx].is_overlay() && !self.in_pane(id) {
228                self.add_study_pane(id);
229            }
230        }
231        let overlays: Vec<_> = self
232            .study_ids
233            .iter()
234            .zip(self.studies.indicators())
235            .filter(|(_, study)| study.is_overlay())
236            .map(|(id, _)| *id)
237            .collect();
238        let series: Vec<_> = self.main_pane_series().map(|item| item.id).collect();
239        let main = &mut self.pane_layout.main;
240        main.retain(|id| *id == SeriesId::MAIN || overlays.contains(id) || series.contains(id));
241        if !main.contains(&SeriesId::MAIN) {
242            main.insert(0, SeriesId::MAIN);
243        }
244        let mut after = main
245            .iter()
246            .position(|id| *id == SeriesId::MAIN)
247            .unwrap_or(0)
248            + 1;
249        for id in series {
250            if !main.contains(&id) {
251                main.insert(after.min(main.len()), id);
252            }
253            after = main.iter().position(|member| *member == id).unwrap_or(0) + 1;
254        }
255        for id in overlays {
256            if !main.contains(&id) {
257                main.push(id);
258            }
259        }
260        if self.pane_layout.maximized.is_some_and(|id| {
261            id != PaneId::MAIN
262                && !self
263                    .pane_layout
264                    .panes
265                    .iter()
266                    .any(|pane| pane.id == id && self.pane_shown(pane))
267        }) {
268            self.pane_layout.maximized = None;
269        }
270    }
271
272    /// The pane height the study `id` prefers, if it declares a usable one.
273    fn study_pane_height(&self, id: SeriesId) -> Option<f32> {
274        self.study_ids
275            .iter()
276            .position(|study| *study == id)
277            .and_then(|idx| self.studies.indicators()[idx].pane().height)
278            .filter(|height| height.is_finite() && *height >= 40.0)
279    }
280
281    /// Appends a pane holding the study `id`, at the study's preferred height.
282    pub(super) fn add_study_pane(&mut self, id: SeriesId) {
283        let height = self.study_pane_height(id);
284        self.add_pane();
285        let pane = self.pane_layout.panes.last_mut().expect("pane was added");
286        pane.height = height;
287        pane.members.push(id);
288    }
289
290    /// Adds an empty pane at the bottom and returns its id. Fill it with
291    /// [`move_series_to_pane`](Self::move_series_to_pane). Once its last series or study leaves
292    /// it the pane is removed, unless [`set_preserve_empty_pane`](Self::set_preserve_empty_pane)
293    /// keeps it.
294    pub fn add_pane(&mut self) -> PaneId {
295        let id = PaneId(self.next_series);
296        self.next_series += 1;
297        self.pane_layout.panes.push(Pane::new(id));
298        id
299    }
300
301    /// Removes a pane with the series and studies it draws. Returns `false` for the price pane
302    /// or an unknown id.
303    pub fn remove_pane(&mut self, pane: PaneId) -> bool {
304        self.sync_panes();
305        if pane == PaneId::MAIN {
306            return false;
307        }
308        let Some(at) = self.pane_index(pane) else {
309            return false;
310        };
311        for id in self.pane_layout.panes[at].members.clone() {
312            self.remove_series(id);
313        }
314        self.pane_layout.panes.retain(|item| item.id != pane);
315        self.sync_panes();
316        true
317    }
318
319    /// The panes from top to bottom, the price pane included. A study that is not an overlay
320    /// starts in a new pane at the bottom.
321    pub fn panes(&self) -> Vec<PaneId> {
322        self.pane_layout
323            .panes
324            .iter()
325            .filter(|pane| self.pane_listed(pane))
326            .map(|pane| pane.id)
327            .collect()
328    }
329
330    /// The bar series and studies drawn in `pane`, in legend order. For the price pane these
331    /// are the main series, the host series not moved to another pane and the overlay studies.
332    /// Empty for an unknown pane.
333    pub fn pane_series(&self, pane: PaneId) -> Vec<SeriesId> {
334        if pane == PaneId::MAIN {
335            self.main_series_present
336                .then_some(SeriesId::MAIN)
337                .into_iter()
338                .chain(
339                    self.price_series
340                        .iter()
341                        .map(|item| item.id)
342                        .filter(|id| !self.in_pane(*id)),
343                )
344                .chain(
345                    self.indicators()
346                        .filter(|(_, study)| study.is_overlay())
347                        .map(|(id, _)| id),
348                )
349                .collect()
350        } else {
351            self.pane_index(pane)
352                .map(|at| {
353                    self.pane_layout.panes[at]
354                        .members
355                        .iter()
356                        .copied()
357                        .filter(|id| self.pane_member(*id))
358                        .collect()
359                })
360                .unwrap_or_default()
361        }
362    }
363
364    /// The pane of `other` that corresponds to the study pane `pane` of this chart, for linking
365    /// the drawings of charts in a [`ChartLayout`](super::ChartLayout).
366    ///
367    /// Panes correspond when they hold the same studies, with equal names and input values in
368    /// the same order. Of several such panes the n-th here corresponds to the n-th there. The
369    /// price pane and panes without studies have no counterpart.
370    pub fn linked_pane(&self, pane: PaneId, other: &Chart) -> Option<PaneId> {
371        let (studies, rank) = self.pane_identity(pane)?;
372        other.identified_pane(&studies, rank)
373    }
374
375    /// The studies of study pane `pane` and its rank among the panes holding the same ones, or
376    /// `None` for the price pane and panes without studies.
377    fn pane_identity(&self, pane: PaneId) -> Option<(Vec<PaneStudy>, usize)> {
378        if pane == PaneId::MAIN {
379            return None;
380        }
381        let studies = self.pane_studies(pane);
382        if studies.is_empty() {
383            return None;
384        }
385        let rank = self
386            .panes()
387            .into_iter()
388            .take_while(|other| *other != pane)
389            .filter(|other| *other != PaneId::MAIN && self.pane_studies(*other) == studies)
390            .count();
391        Some((studies, rank))
392    }
393
394    /// The `rank`-th study pane holding `studies`, from the top.
395    fn identified_pane(&self, studies: &[PaneStudy], rank: usize) -> Option<PaneId> {
396        self.panes()
397            .into_iter()
398            .filter(|pane| *pane != PaneId::MAIN && self.pane_studies(*pane) == studies)
399            .nth(rank)
400    }
401
402    /// The identity of study pane `pane` for a stored drawing, as in [`Self::linked_pane`].
403    #[cfg(feature = "drawings")]
404    pub(crate) fn stored_pane(&self, pane: PaneId) -> Option<StoredPane> {
405        self.pane_identity(pane)
406            .map(|(studies, rank)| StoredPane { studies, rank })
407    }
408
409    /// The study a drawing stored in `stored` belongs to on this chart: the first study of the
410    /// corresponding pane, or `None`, the price pane, when there is none.
411    #[cfg(feature = "drawings")]
412    pub(crate) fn stored_pane_owner(&self, stored: &StoredPane) -> Option<usize> {
413        let pane = self.identified_pane(&stored.studies, stored.rank)?;
414        self.pane_series(pane).first().map(|id| id.0)
415    }
416
417    /// The name and input values of each study in `pane`, in legend order.
418    fn pane_studies(&self, pane: PaneId) -> Vec<PaneStudy> {
419        self.pane_series(pane)
420            .into_iter()
421            .filter_map(|id| self.indicators().find(|(study, _)| *study == id))
422            .map(|(_, study)| {
423                // Parsed from text like a stored pane's values, which can differ in the last bit.
424                let inputs = study.inputs().into_iter().map(|input| {
425                    let text = serde_json::to_string(&input.value).unwrap_or_default();
426                    (input.id, serde_json::from_str(&text).unwrap_or_default())
427                });
428                (study.name().to_owned(), inputs.collect())
429            })
430            .collect()
431    }
432
433    /// The pane drawing the bar series or study `series`, or `None` for an unknown id.
434    pub fn series_pane(&self, series: SeriesId) -> Option<PaneId> {
435        if let Some(pane) = self
436            .pane_layout
437            .panes
438            .iter()
439            .find(|pane| pane.members.contains(&series))
440            .filter(|_| self.pane_member(series))
441        {
442            return Some(pane.id);
443        }
444        let main = if series == SeriesId::MAIN {
445            self.main_series_present
446        } else {
447            self.price_series.iter().any(|item| item.id == series)
448                || self
449                    .indicators()
450                    .any(|(id, study)| id == series && study.is_overlay())
451        };
452        main.then_some(PaneId::MAIN)
453    }
454
455    /// Moves a host series or study into `pane`, after the series and studies already there.
456    /// A study moved to the price pane becomes an overlay. Below the price pane a host series
457    /// shares the pane's price scale instead of its [`SeriesScale`](crate::model::SeriesScale).
458    /// Returns `false` for the main series, an unknown id or pane, or a custom study
459    /// presentation that rejects placement edits.
460    pub fn move_series_to_pane(&mut self, series: SeriesId, pane: PaneId) -> bool {
461        self.sync_panes();
462        if self.pane_index(pane).is_none() {
463            return false;
464        }
465        if let Some(idx) = self.study_ids.iter().position(|id| *id == series) {
466            self.record_study(series);
467            if !self.studies.indicators_mut()[idx].set_overlay(pane == PaneId::MAIN) {
468                return false;
469            }
470        } else if !self.price_series.iter().any(|item| item.id == series) {
471            return false;
472        }
473        if self.series_pane(series) != Some(pane) {
474            self.leave_panes(series);
475            if let Some(target) = self
476                .pane_layout
477                .panes
478                .iter_mut()
479                .find(|item| item.id == pane && pane != PaneId::MAIN)
480            {
481                target.members.push(series);
482            }
483        }
484        self.sync_panes();
485        true
486    }
487
488    /// Moves a host series or study into a new pane at the bottom.
489    fn move_to_new_pane(&mut self, series: SeriesId) -> bool {
490        self.sync_panes();
491        let pane = self.add_pane();
492        let moved = self.move_series_to_pane(series, pane);
493        if moved {
494            let height = self.study_pane_height(series);
495            if let Some(item) = self
496                .pane_layout
497                .panes
498                .iter_mut()
499                .find(|item| item.id == pane)
500            {
501                item.height = height;
502            }
503        } else {
504            self.pane_layout.panes.retain(|item| item.id != pane);
505        }
506        moved
507    }
508
509    /// Moves a study to the main pane, another study's pane, or its own new pane.
510    /// Use `SeriesId::MAIN`, the destination study id, or the moved study's own id respectively.
511    /// Returns `false` for an unknown id or a custom presentation that rejects placement edits.
512    /// [`move_series_to_pane`](Self::move_series_to_pane) addresses panes by [`PaneId`].
513    pub fn move_indicator_to_pane(&mut self, study: SeriesId, target: SeriesId) -> bool {
514        self.sync_panes();
515        if !self.study_ids.contains(&study) {
516            return false;
517        }
518        if target == study {
519            return self.move_to_new_pane(study);
520        }
521        let pane = if target == SeriesId::MAIN {
522            PaneId::MAIN
523        } else {
524            match self.series_pane(target) {
525                Some(pane) if pane != PaneId::MAIN && self.study_ids.contains(&target) => pane,
526                _ => return false,
527            }
528        };
529        self.move_series_to_pane(study, pane)
530    }
531
532    /// Moves `pane` to position `index` from the top, the price pane counting as a pane; an
533    /// index past the bottom moves it to the bottom. Returns `false` for an unknown pane.
534    pub fn move_pane(&mut self, pane: PaneId, index: usize) -> bool {
535        self.sync_panes();
536        let Some(at) = self.pane_index(pane) else {
537            return false;
538        };
539        let item = self.pane_layout.panes.remove(at);
540        let index = index.min(self.pane_layout.panes.len());
541        self.pane_layout.panes.insert(index, item);
542        true
543    }
544
545    /// Swaps the positions of two panes, either of which may be the price pane. Returns `false`
546    /// for an unknown pane.
547    pub fn swap_panes(&mut self, first: PaneId, second: PaneId) -> bool {
548        self.sync_panes();
549        let (Some(first), Some(second)) = (self.pane_index(first), self.pane_index(second)) else {
550            return false;
551        };
552        self.pane_layout.panes.swap(first, second);
553        true
554    }
555
556    /// The height of `pane` in logical pixels as drawn in the last frame, or `None` when it was
557    /// not drawn, such as a hidden pane or one left out while another is maximized.
558    pub fn pane_height(&self, pane: PaneId) -> Option<f32> {
559        self.pane_layout
560            .panes
561            .iter()
562            .find(|item| item.id == pane)
563            .map(|item| item.rect.height())
564            .filter(|height| *height > 0.0)
565    }
566
567    /// Gives `pane` this height in logical pixels from the next frame by setting its stretch
568    /// factor, so that later resizes keep the proportions. The other expanded panes share the
569    /// rest by their own factors, and every pane keeps at least 40 pixels. Returns `false` for
570    /// an unknown pane or a height that is not finite and positive.
571    pub fn set_pane_height(&mut self, pane: PaneId, height: f32) -> bool {
572        self.sync_panes();
573        let Some(at) = self.pane_index(pane) else {
574            return false;
575        };
576        if !(height.is_finite() && height > 0.0) {
577            return false;
578        }
579        self.pane_layout.panes[at].height = Some(height);
580        true
581    }
582
583    /// The stretch factor of `pane`, or `None` for an unknown pane. Expanded panes share the
584    /// chart height in proportion to their factors: 3 for the price pane and 1 for the others
585    /// by default. A study's preferred pane height, a dragged separator and
586    /// [`set_pane_height`](Self::set_pane_height) set it on the frame that lays the pane out.
587    pub fn pane_stretch_factor(&self, pane: PaneId) -> Option<f32> {
588        self.pane_index(pane)
589            .map(|at| self.pane_layout.panes[at].stretch)
590    }
591
592    /// Sets the stretch factor of `pane`, replacing a height not yet applied. Returns `false`
593    /// for an unknown pane or a factor that is not finite and positive.
594    pub fn set_pane_stretch_factor(&mut self, pane: PaneId, factor: f32) -> bool {
595        self.sync_panes();
596        let Some(at) = self.pane_index(pane) else {
597            return false;
598        };
599        if !(factor.is_finite() && factor > 0.0) {
600            return false;
601        }
602        let pane = &mut self.pane_layout.panes[at];
603        pane.stretch = factor;
604        pane.height = None;
605        true
606    }
607
608    /// Keeps `pane` when its last series or study leaves it, instead of removing it. Returns
609    /// `false` for the price pane or an unknown id.
610    pub fn set_preserve_empty_pane(&mut self, pane: PaneId, preserve: bool) -> bool {
611        self.sync_panes();
612        let Some(at) = self.pane_index(pane).filter(|_| pane != PaneId::MAIN) else {
613            return false;
614        };
615        self.pane_layout.panes[at].preserve = preserve;
616        true
617    }
618
619    /// Whether `pane` is kept when it becomes empty; the price pane always is.
620    pub fn preserves_empty_pane(&self, pane: PaneId) -> bool {
621        pane == PaneId::MAIN
622            || self
623                .pane_index(pane)
624                .is_some_and(|at| self.pane_layout.panes[at].preserve)
625    }
626
627    /// Sets the behaviour and colours of the pane separators (builder pattern).
628    pub fn with_pane_options(mut self, options: PaneOptions) -> Self {
629        self.pane_options = options;
630        self
631    }
632
633    /// The behaviour and colours of the pane separators.
634    pub fn pane_options(&self) -> &PaneOptions {
635        &self.pane_options
636    }
637
638    /// The behaviour and colours of the pane separators, to change them from the next frame.
639    pub fn pane_options_mut(&mut self) -> &mut PaneOptions {
640        &mut self.pane_options
641    }
642}
643
644impl Chart {
645    /// Moves a study to a new pane directly above or below its current pane.
646    /// Returns `false` for an unknown study or a presentation that rejects placement edits.
647    #[cfg(feature = "ui")]
648    pub(crate) fn move_indicator_to_new_pane(&mut self, study: SeriesId, above: bool) -> bool {
649        self.sync_panes();
650        let Some(from) = self
651            .series_pane(study)
652            .and_then(|pane| self.pane_index(pane))
653        else {
654            return false;
655        };
656        let source = &self.pane_layout.panes[from];
657        let kept = source.id == PaneId::MAIN || source.preserve || source.members.len() > 1;
658        if !self.move_indicator_to_pane(study, study) {
659            return false;
660        }
661        let to = from + usize::from(kept && !above);
662        let pane = self
663            .series_pane(study)
664            .expect("study was moved to a new pane");
665        self.move_pane(pane, to)
666    }
667
668    /// Moves study `id` in the draw order of its pane, among the pane's studies and, in the main
669    /// pane, the price series. Returns `false` for an id that is not a study of the chart.
670    pub fn reorder_indicator(&mut self, id: SeriesId, order: VisualOrder) -> bool {
671        if !self.study_ids.contains(&id) {
672            return false;
673        }
674        self.sync_panes();
675        let layout = &mut self.pane_layout;
676        let Some(items) = std::iter::once(&mut layout.main)
677            .chain(layout.panes.iter_mut().map(|pane| &mut pane.members))
678            .find(|items| items.contains(&id))
679        else {
680            return false;
681        };
682        let at = items.iter().position(|item| *item == id).unwrap();
683        let last = items.len() - 1;
684        let to = match order {
685            VisualOrder::BringToFront => last,
686            VisualOrder::BringForward => (at + 1).min(last),
687            VisualOrder::SendBackward => at.saturating_sub(1),
688            VisualOrder::SendToBack => 0,
689        };
690        let item = items.remove(at);
691        items.insert(to, item);
692        true
693    }
694}
695
696/// Pre-computed layout rectangles for the chart's sub-regions.
697///
698/// Use [`Chart::calculate_layout_rects`] to obtain these rects for a given
699/// widget area. They are useful for external hit-testing (e.g., determining
700/// whether a click landed on the price area, volume area, or legend).
701#[derive(Clone, Copy, Debug)]
702#[non_exhaustive]
703pub struct ChartLayoutRects {
704    /// The overall widget rect (entire chart area including axes and padding).
705    pub widget_rect: Rect,
706    /// The main price/candle area where OHLC data is rendered.
707    pub price_rect: Rect,
708    /// The volume area inside the price pane (empty if volume is hidden).
709    pub volume_rect: Rect,
710    /// The legend/OHLC info area at the top of the chart.
711    pub legend_rect: Rect,
712}
713
714impl Default for ChartLayoutRects {
715    fn default() -> Self {
716        Self {
717            widget_rect: Rect::NOTHING,
718            price_rect: Rect::NOTHING,
719            volume_rect: Rect::NOTHING,
720            legend_rect: Rect::NOTHING,
721        }
722    }
723}
724
725impl Chart {
726    /// Calculate the layout sub-rects for a given widget rect.
727    ///
728    /// Given the overall widget area, this computes where the price chart,
729    /// volume bars, and legend/OHLC header will be drawn. Useful for
730    /// external hit-testing, overlay placement, or custom drawing on top
731    /// of specific chart regions.
732    ///
733    /// The layout respects current config flags like `show_ohlc_info`,
734    /// `show_time_labels`, and `show_volume`.
735    pub fn calculate_layout_rects(&self, widget_rect: Rect) -> ChartLayoutRects {
736        self.pane_layout_rects(widget_rect, self.config.show_time_labels)
737    }
738
739    pub(in crate::widget) fn pane_layout_rects(
740        &self,
741        widget_rect: Rect,
742        show_time_labels: bool,
743    ) -> ChartLayoutRects {
744        let padding = self
745            .config
746            .padding
747            .max(0.0)
748            .min(widget_rect.width().min(widget_rect.height()).max(0.0) / 2.0);
749        let study_axes = if self.config.show_left_axis || self.config.show_right_axis {
750            self.studies
751                .indicators()
752                .iter()
753                .filter(|study| {
754                    study.is_visible()
755                        && study.is_overlay()
756                        && !study.is_volume()
757                        && study.scale() == crate::studies::StudyScale::Independent
758                })
759                .count() as f32
760                * self
761                    .measured_axis_width
762                    .max(self.config.right_axis_width)
763                    .max(self.config.left_axis_width)
764        } else {
765            0.0
766        };
767        let series_axes = self.series_axis_keys();
768        let series_left = series_axes
769            .iter()
770            .filter(|key| **key != crate::model::SeriesScale::Right)
771            .count() as f32
772            * self
773                .measured_axis_width
774                .max(self.config.right_axis_width)
775                .max(self.config.left_axis_width);
776        let series_right = if series_axes.contains(&crate::model::SeriesScale::Right) {
777            self.measured_axis_width.max(self.config.right_axis_width)
778        } else {
779            0.0
780        };
781        let study_axes = study_axes + series_left;
782        let left_axis = if self.config.show_left_axis {
783            self.config.left_axis_width.max(self.measured_axis_width)
784        } else {
785            0.0
786        };
787        let right_axis = if self.config.show_right_axis {
788            self.config.right_axis_width.max(self.measured_axis_width)
789        } else {
790            0.0
791        } + series_right;
792        let study_axes = study_axes
793            .min((widget_rect.width() - 2.0 * padding - left_axis - right_axis).max(0.0) * 0.5);
794        let left_axis = left_axis + study_axes;
795        let bottom_padding = if show_time_labels {
796            self.time_axis_height()
797        } else {
798            0.0
799        };
800        let price_rect = Rect::from_min_size(
801            widget_rect.min + Vec2::new((padding + left_axis).min(widget_rect.width()), padding),
802            Vec2::new(
803                (widget_rect.width() - 2.0 * padding - left_axis - right_axis).max(0.0),
804                (widget_rect.height() - 2.0 * padding - bottom_padding).max(0.0),
805            ),
806        );
807        let legend_rect = if self.config.show_ohlc_info {
808            price_rect.shrink(8.0).intersect(Rect::from_min_size(
809                price_rect.min + Vec2::splat(8.0),
810                Vec2::new(price_rect.width(), 24.0),
811            ))
812        } else {
813            Rect::NOTHING
814        };
815        let volume_rect = if self.shows_volume() {
816            volume::overlay_rect(price_rect, &self.config)
817        } else {
818            Rect::ZERO
819        };
820
821        ChartLayoutRects {
822            widget_rect,
823            price_rect,
824            volume_rect,
825            legend_rect,
826        }
827    }
828}
829
830#[cfg(all(test, feature = "studies"))]
831mod tests {
832    use crate::widget::test_frames::chart;
833    use egui::{Context, Pos2, RawInput, Rect, Vec2};
834
835    /// A host series moved into a pane leaves the price scale, panes share the height by
836    /// stretch factor across resizes, and a pane can sit above the price pane.
837    #[test]
838    fn host_panes_take_series_share_height_by_stretch_and_reorder() {
839        use crate::widget::{PaneId, SeriesId, SeriesOptions};
840        let ctx = Context::default();
841        let mut chart = chart(&[100.0], &[110.0]);
842        let series = chart.add_series(SeriesOptions::default());
843        let bars = (chart.bars(SeriesId::MAIN).iter().enumerate())
844            .map(|(i, bar)| {
845                let close = 1000.0 + i as f64;
846                crate::model::Bar::new(bar.time, close, close, close, close, 1.0)
847            })
848            .collect();
849        chart.set_data(series, bars);
850        let pane = chart.add_pane();
851        assert!(chart.move_series_to_pane(series, pane));
852        assert!(chart.set_pane_stretch_factor(pane, 3.0));
853        assert!(chart.move_pane(pane, 0));
854        assert_eq!(chart.panes(), [pane, PaneId::MAIN]);
855        assert_eq!(chart.pane_series(pane), [series]);
856        assert_eq!(chart.pane_series(PaneId::MAIN), [SeriesId::MAIN]);
857        let show = |chart: &mut crate::widget::Chart, height: f32| {
858            ctx.run_ui(
859                RawInput {
860                    screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, height))),
861                    ..Default::default()
862                },
863                |ui| {
864                    egui::CentralPanel::default().show(ui, |ui| chart.show(ui));
865                },
866            )
867            .drop_without_applying_deltas();
868        };
869        show(&mut chart, 600.0);
870        let heights = |chart: &crate::widget::Chart| {
871            (
872                chart.pane_height(pane).unwrap(),
873                chart.pane_height(PaneId::MAIN).unwrap(),
874            )
875        };
876        let (top, main) = heights(&chart);
877        assert!((top - main).abs() < 0.01, "equal factors share equally");
878        assert!(chart.pane_layout.panes[0].rect.bottom() <= chart.get_rendered_price_rect().top());
879        let range = chart.pane_layout.panes[0].scale.range();
880        assert!(range.min < 1000.0 && range.max > 1099.0);
881        assert!(chart.get_chart_mapping().price.range().max < 1000.0);
882        assert!(chart.set_pane_height(pane, 200.0));
883        show(&mut chart, 600.0);
884        let (top, main) = heights(&chart);
885        assert!((top - 200.0).abs() < 0.01);
886        let share = top / (top + main);
887        show(&mut chart, 400.0);
888        let (top, main) = heights(&chart);
889        assert!(
890            (top / (top + main) - share).abs() < 1e-4,
891            "a resize keeps the shares"
892        );
893        // A factor near f32::MAX gives the pane most of the height instead of overflowing.
894        assert!(chart.set_pane_stretch_factor(pane, 3e38));
895        show(&mut chart, 600.0);
896        let (top, main) = heights(&chart);
897        assert!(top.is_finite() && top > main, "{top} {main}");
898        assert!(chart.set_preserve_empty_pane(pane, true));
899        assert!(chart.move_series_to_pane(series, PaneId::MAIN));
900        assert_eq!(chart.panes(), [pane, PaneId::MAIN]);
901        assert!(chart.set_preserve_empty_pane(pane, false));
902        assert!(chart.move_series_to_pane(series, pane));
903        assert!(chart.move_series_to_pane(series, PaneId::MAIN));
904        assert_eq!(chart.panes(), [PaneId::MAIN]);
905    }
906
907    #[test]
908    fn pane_defaults_do_not_depend_on_the_study_name() {
909        let ctx = Context::default();
910        let mut chart = chart(&[100.0], &[110.0]);
911        chart.add_indicator(Box::new(
912            crate::studies::CustomIndicator::new("RSI", |bars| {
913                bars.iter()
914                    .map(|_| crate::studies::IndicatorValue::Single(200.0))
915                    .collect()
916            })
917            .with_overlay(false),
918        ));
919        crate::widget::test_frames::frame(&ctx, &mut chart, 0.0, Vec::new());
920        let pane = &chart.get_rendered_indicator_panes()[0];
921        // A flat value of 200 gets 10% of a 1% span (2): [199.8, 200.2].
922        assert!((pane.y_min - 199.8).abs() < 1e-9);
923        assert!((pane.y_max - 200.2).abs() < 1e-9);
924    }
925}