Skip to main content

egui_charts/widget/
layout.rs

1//! Tiled and tabbed charts with independent state and timestamp-based synchronization.
2
3use super::keyboard::ShortcutRequest;
4use super::{Chart, VisibleTimeRange};
5use crate::config::KeyboardAction;
6use crate::localization as l10n;
7use crate::model::Timeframe;
8use crate::theme::ThemeExt;
9use egui::{Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2};
10
11#[cfg(feature = "drawings")]
12mod drawings;
13#[cfg(feature = "drawings")]
14use drawings::DrawingLinks;
15
16/// Multi-chart arrangement. Changing presets retains the host's hidden charts. Charts fill a preset
17/// in slot order, row by row from the top; presets with a large chart at the left or right fill
18/// column by column from the left.
19#[derive(
20    Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize,
21)]
22pub enum ChartLayoutMode {
23    /// One chart.
24    #[default]
25    Single,
26    /// Two columns.
27    TwoHorizontal,
28    /// Two rows.
29    TwoVertical,
30    /// Three charts, with the last spanning the bottom row.
31    Three,
32    /// Three columns.
33    ThreeHorizontal,
34    /// Three rows.
35    ThreeVertical,
36    /// One chart in the left half, two stacked in the right half.
37    ThreeLargeLeft,
38    /// Two charts stacked in the left half, one in the right half.
39    ThreeLargeRight,
40    /// One chart in the top half, two side by side in the bottom half; [`Three`](Self::Three) is
41    /// the reverse.
42    ThreeLargeTop,
43    /// Two columns and two rows.
44    Four,
45    /// Four columns.
46    FourHorizontal,
47    /// Four rows.
48    FourVertical,
49    /// One chart in the left half, three stacked in the right half.
50    FourLargeLeft,
51    /// Three charts stacked in the left half, one in the right half.
52    FourLargeRight,
53    /// One chart in the top half, three side by side in the bottom half.
54    FourLargeTop,
55    /// Three charts side by side in the top half, one in the bottom half.
56    FourLargeBottom,
57    /// Five charts in three columns.
58    Five,
59    /// Five columns.
60    FiveHorizontal,
61    /// Five rows.
62    FiveVertical,
63    /// One chart in the left half, four stacked in the right half.
64    FiveLargeLeft,
65    /// Four charts stacked in the left half, one in the right half.
66    FiveLargeRight,
67    /// One chart in the top half, four side by side in the bottom half.
68    FiveLargeTop,
69    /// Four charts side by side in the top half, one in the bottom half.
70    FiveLargeBottom,
71    /// Three columns and two rows.
72    Six,
73    /// Six columns.
74    SixHorizontal,
75    /// Six rows.
76    SixVertical,
77    /// One chart in the left half, five stacked in the right half.
78    SixLargeLeft,
79    /// Five charts stacked in the left half, one in the right half.
80    SixLargeRight,
81    /// One chart in the top half, five side by side in the bottom half.
82    SixLargeTop,
83    /// Five charts side by side in the top half, one in the bottom half.
84    SixLargeBottom,
85    /// Seven charts in four columns.
86    Seven,
87    /// Four columns and two rows.
88    Eight,
89    /// Three columns and three rows.
90    Nine,
91    /// Ten charts in four columns.
92    Ten,
93    /// Eleven charts in four columns.
94    Eleven,
95    /// Four columns and three rows.
96    Twelve,
97    /// Thirteen charts in four columns.
98    Thirteen,
99    /// Fourteen charts in four columns.
100    Fourteen,
101    /// Fifteen charts in four columns.
102    Fifteen,
103    /// Four columns and four rows.
104    Sixteen,
105    /// One tab per supplied chart, up to sixteen.
106    Tabs,
107}
108
109impl ChartLayoutMode {
110    /// All selectable presets, by chart count.
111    pub fn all() -> &'static [Self] {
112        &[
113            Self::Single,
114            Self::TwoHorizontal,
115            Self::TwoVertical,
116            Self::Three,
117            Self::ThreeHorizontal,
118            Self::ThreeVertical,
119            Self::ThreeLargeLeft,
120            Self::ThreeLargeRight,
121            Self::ThreeLargeTop,
122            Self::Four,
123            Self::FourHorizontal,
124            Self::FourVertical,
125            Self::FourLargeLeft,
126            Self::FourLargeRight,
127            Self::FourLargeTop,
128            Self::FourLargeBottom,
129            Self::Five,
130            Self::FiveHorizontal,
131            Self::FiveVertical,
132            Self::FiveLargeLeft,
133            Self::FiveLargeRight,
134            Self::FiveLargeTop,
135            Self::FiveLargeBottom,
136            Self::Six,
137            Self::SixHorizontal,
138            Self::SixVertical,
139            Self::SixLargeLeft,
140            Self::SixLargeRight,
141            Self::SixLargeTop,
142            Self::SixLargeBottom,
143            Self::Seven,
144            Self::Eight,
145            Self::Nine,
146            Self::Ten,
147            Self::Eleven,
148            Self::Twelve,
149            Self::Thirteen,
150            Self::Fourteen,
151            Self::Fifteen,
152            Self::Sixteen,
153            Self::Tabs,
154        ]
155    }
156
157    /// Human-readable preset name, distinct for every preset.
158    pub fn label(&self) -> &'static str {
159        match self {
160            Self::Single => "Single",
161            Self::TwoHorizontal => "2 Horizontal",
162            Self::TwoVertical => "2 Vertical",
163            Self::Three => "2 Top, 1 Bottom",
164            Self::ThreeHorizontal => "3 Horizontal",
165            Self::ThreeVertical => "3 Vertical",
166            Self::ThreeLargeLeft => "1 Left, 2 Right",
167            Self::ThreeLargeRight => "2 Left, 1 Right",
168            Self::ThreeLargeTop => "1 Top, 2 Bottom",
169            Self::Four => "2x2 Grid",
170            Self::FourHorizontal => "4 Horizontal",
171            Self::FourVertical => "4 Vertical",
172            Self::FourLargeLeft => "1 Left, 3 Right",
173            Self::FourLargeRight => "3 Left, 1 Right",
174            Self::FourLargeTop => "1 Top, 3 Bottom",
175            Self::FourLargeBottom => "3 Top, 1 Bottom",
176            Self::Five => "3 Top, 2 Bottom",
177            Self::FiveHorizontal => "5 Horizontal",
178            Self::FiveVertical => "5 Vertical",
179            Self::FiveLargeLeft => "1 Left, 4 Right",
180            Self::FiveLargeRight => "4 Left, 1 Right",
181            Self::FiveLargeTop => "1 Top, 4 Bottom",
182            Self::FiveLargeBottom => "4 Top, 1 Bottom",
183            Self::Six => "3x2 Grid",
184            Self::SixHorizontal => "6 Horizontal",
185            Self::SixVertical => "6 Vertical",
186            Self::SixLargeLeft => "1 Left, 5 Right",
187            Self::SixLargeRight => "5 Left, 1 Right",
188            Self::SixLargeTop => "1 Top, 5 Bottom",
189            Self::SixLargeBottom => "5 Top, 1 Bottom",
190            Self::Seven => "7 Charts",
191            Self::Eight => "4x2 Grid",
192            Self::Nine => "3x3 Grid",
193            Self::Ten => "10 Charts",
194            Self::Eleven => "11 Charts",
195            Self::Twelve => "4x3 Grid",
196            Self::Thirteen => "13 Charts",
197            Self::Fourteen => "14 Charts",
198            Self::Fifteen => "15 Charts",
199            Self::Sixteen => "4x4 Grid",
200            Self::Tabs => "Tabs",
201        }
202    }
203
204    /// Number of charts in a preset; tabs can show up to sixteen supplied charts.
205    pub fn chart_count(&self) -> usize {
206        if *self == Self::Tabs {
207            return 16;
208        }
209        let (Tracks::Rows(cells) | Tracks::Columns(cells)) = self.tracks();
210        cells.iter().sum()
211    }
212
213    /// Maximum columns and rows. A row or column with fewer cells than another stretches them to
214    /// fill it.
215    pub fn grid_size(&self) -> (usize, usize) {
216        match self.tracks() {
217            Tracks::Rows(cells) => (cells.iter().copied().max().unwrap_or(1), cells.len()),
218            Tracks::Columns(cells) => (cells.len(), cells.iter().copied().max().unwrap_or(1)),
219        }
220    }
221
222    /// How the preset divides its rectangle. Tabs show one chart at a time in one cell.
223    fn tracks(&self) -> Tracks {
224        use Tracks::{Columns, Rows};
225        match self {
226            Self::Single | Self::Tabs => Rows(&[1]),
227            Self::TwoHorizontal => Rows(&[2]),
228            Self::TwoVertical => Rows(&[1, 1]),
229            Self::Three => Rows(&[2, 1]),
230            Self::ThreeHorizontal => Rows(&[3]),
231            Self::ThreeVertical => Rows(&[1, 1, 1]),
232            Self::ThreeLargeLeft => Columns(&[1, 2]),
233            Self::ThreeLargeRight => Columns(&[2, 1]),
234            Self::ThreeLargeTop => Rows(&[1, 2]),
235            Self::Four => Rows(&[2, 2]),
236            Self::FourHorizontal => Rows(&[4]),
237            Self::FourVertical => Rows(&[1, 1, 1, 1]),
238            Self::FourLargeLeft => Columns(&[1, 3]),
239            Self::FourLargeRight => Columns(&[3, 1]),
240            Self::FourLargeTop => Rows(&[1, 3]),
241            Self::FourLargeBottom => Rows(&[3, 1]),
242            Self::Five => Rows(&[3, 2]),
243            Self::FiveHorizontal => Rows(&[5]),
244            Self::FiveVertical => Rows(&[1, 1, 1, 1, 1]),
245            Self::FiveLargeLeft => Columns(&[1, 4]),
246            Self::FiveLargeRight => Columns(&[4, 1]),
247            Self::FiveLargeTop => Rows(&[1, 4]),
248            Self::FiveLargeBottom => Rows(&[4, 1]),
249            Self::Six => Rows(&[3, 3]),
250            Self::SixHorizontal => Rows(&[6]),
251            Self::SixVertical => Rows(&[1, 1, 1, 1, 1, 1]),
252            Self::SixLargeLeft => Columns(&[1, 5]),
253            Self::SixLargeRight => Columns(&[5, 1]),
254            Self::SixLargeTop => Rows(&[1, 5]),
255            Self::SixLargeBottom => Rows(&[5, 1]),
256            Self::Seven => Rows(&[4, 3]),
257            Self::Eight => Rows(&[4, 4]),
258            Self::Nine => Rows(&[3, 3, 3]),
259            Self::Ten => Rows(&[4, 4, 2]),
260            Self::Eleven => Rows(&[4, 4, 3]),
261            Self::Twelve => Rows(&[4, 4, 4]),
262            Self::Thirteen => Rows(&[4, 4, 4, 1]),
263            Self::Fourteen => Rows(&[4, 4, 4, 2]),
264            Self::Fifteen => Rows(&[4, 4, 4, 3]),
265            Self::Sixteen => Rows(&[4, 4, 4, 4]),
266        }
267    }
268}
269
270impl std::fmt::Display for ChartLayoutMode {
271    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
272        f.write_str(self.label())
273    }
274}
275
276/// The cells of a preset: rows from the top or columns from the left, each holding the given
277/// number of equal cells. Charts fill one row or column after the other.
278enum Tracks {
279    Rows(&'static [usize]),
280    Columns(&'static [usize]),
281}
282
283/// Independent synchronization switches within each layout group.
284#[derive(Debug, Clone, PartialEq)]
285pub struct ChartSyncOptions {
286    /// Synchronize scrolling and zooming using UTC plot edges.
287    pub sync_time_axis: bool,
288    /// Synchronize the hovered timestamp.
289    pub sync_crosshair: bool,
290    /// Synchronize completed main-pane drawings between charts of the same symbol.
291    /// Study-owned drawings stay local because study ids and scales belong to each chart.
292    /// Charts that keep their drawings in the same storage are left to it: they read each other's
293    /// stored drawings whether this is on or off, so a drawing reaches them once a chart history
294    /// edit writes it. Only drawings that are not saveable still sync between them here.
295    pub sync_drawings: bool,
296    /// Synchronize symbol changes. A host without a feed must supply replacement bars.
297    pub sync_symbol: bool,
298    /// Synchronize interval changes. A host without a feed must supply replacement bars.
299    pub sync_timeframe: bool,
300    /// Apply host date-range selections to the group, independently of scrolling and zooming.
301    pub sync_date_range: bool,
302}
303
304impl Default for ChartSyncOptions {
305    fn default() -> Self {
306        Self {
307            sync_time_axis: true,
308            sync_crosshair: true,
309            sync_drawings: false,
310            sync_symbol: false,
311            sync_timeframe: false,
312            sync_date_range: false,
313        }
314    }
315}
316
317/// Container state for up to sixteen host-owned charts. Supply charts in stable slot order.
318///
319/// Each chart keeps its own feed, settings, scales, studies and drawings. An unnamed group
320/// synchronizes with other unnamed charts; named groups are isolated. Save this state and the
321/// charts through host storage as needed. Synchronization includes supplied slots hidden by a
322/// grid preset. The container does not acquire data or persist it. Charts that keep their
323/// drawings in the same storage (`Chart::set_storage`) read the drawings of their symbol again
324/// after another of them wrote them, in any group.
325///
326/// ```
327/// # fn example(ui: &mut egui::Ui) {
328/// use egui_charts::widget::{Chart, ChartLayout, ChartLayoutMode};
329/// let mut a = Chart::new(egui_charts::model::BarData::new());
330/// let mut b = Chart::new(egui_charts::model::BarData::new());
331/// let mut layout = ChartLayout::default();
332/// layout.mode = ChartLayoutMode::TwoHorizontal;
333/// layout.show(ui, &mut [&mut a, &mut b]);
334/// # }
335/// ```
336#[derive(Clone, Default)]
337pub struct ChartLayout {
338    /// The selected grid or tab preset.
339    pub mode: ChartLayoutMode,
340    /// Synchronization controls, applied within each group.
341    pub sync: ChartSyncOptions,
342    active: usize,
343    /// The chart slot Alt+Enter shows alone in place of the preset.
344    maximized: Option<usize>,
345    focus_active: bool,
346    groups: [Option<String>; 16],
347    detached: [bool; 16],
348    previous: [Option<(String, Option<Timeframe>)>; 16],
349    previous_ranges: [Option<VisibleTimeRange>; 16],
350    previous_bars: [BarsKey; 16],
351    pending_ranges: [Option<PendingRange>; 16],
352    #[cfg(feature = "drawings")]
353    drawings: DrawingLinks,
354    previous_preset: Option<(ChartLayoutMode, usize)>,
355}
356
357/// Identifies a chart's bars: it changes when they are replaced, not when they stream.
358type BarsKey = (u64, Option<chrono::DateTime<chrono::Utc>>);
359
360fn bars_key(chart: &Chart) -> BarsKey {
361    (
362        chart.state.revision(),
363        chart.state.shown().first().map(|bar| bar.time),
364    )
365}
366
367/// A range a chart fits once bars of its new interval replace those it held at the switch.
368#[derive(Clone, Copy)]
369struct PendingRange {
370    range: VisibleTimeRange,
371    /// The [`Chart::sync_source`] of the chart it came from, `None` for the chart's own range.
372    source: Option<(Timeframe, f64)>,
373    bars: BarsKey,
374}
375
376impl ChartLayout {
377    /// Active chart slot, clamped to supplied charts by [`Self::show`].
378    pub fn active(&self) -> usize {
379        self.active
380    }
381
382    /// Selects a chart slot and focuses its canvas when shown; out-of-range indices are ignored.
383    pub fn set_active(&mut self, index: usize) {
384        if index < 16 {
385            self.active = index;
386            self.focus_active = true;
387        }
388    }
389
390    /// Assigns a named synchronization group; `None` restores the unnamed group.
391    pub fn set_sync_group(&mut self, index: usize, group: Option<String>) {
392        if let Some(slot) = self.groups.get_mut(index) {
393            *slot = group;
394        }
395    }
396
397    /// The group assigned to a slot, or `None` for the unnamed group or an invalid slot.
398    pub fn sync_group(&self, index: usize) -> Option<&str> {
399        self.groups.get(index).and_then(|g| g.as_deref())
400    }
401
402    /// Detaches a chart into an immediate viewport. Backends without native multi-window
403    /// support embed the viewport. Closing it returns the chart to its grid or tab.
404    pub fn set_detached(&mut self, index: usize, detached: bool) {
405        if let Some(slot) = self.detached.get_mut(index) {
406            *slot = detached;
407        }
408    }
409
410    /// Whether a chart is detached.
411    pub fn is_detached(&self, index: usize) -> bool {
412        self.detached.get(index).copied().unwrap_or(false)
413    }
414
415    /// Fits an explicit date selection to the active chart, or its synchronization group
416    /// when `sync_date_range` is enabled. Call after the charts have rendered at their sizes.
417    pub fn apply_date_range(&self, charts: &mut [&mut Chart], range: super::VisibleTimeRange) {
418        let Some(active) = charts.get_mut(self.active) else {
419            return;
420        };
421        let source = active
422            .fit_time_range(range, f64::INFINITY)
423            .then(|| active.sync_source())
424            .flatten();
425        for (index, chart) in charts.iter_mut().take(16).enumerate() {
426            if index != self.active
427                && self.sync.sync_date_range
428                && self.same_group(index, self.active)
429            {
430                chart.apply_synced_time_range_from(range, source);
431            }
432        }
433    }
434
435    fn same_group(&self, a: usize, b: usize) -> bool {
436        self.groups[a] == self.groups[b]
437    }
438
439    /// Shows the preset and synchronizes its charts. Click or scroll a panel to activate it.
440    /// Its corner menu offers detachment and named groups. The active border is painted
441    /// inside the chart; stacked charts have a two-point gap and side-by-side charts meet at their
442    /// edge.
443    /// Extra charts remain owned by the host and are not painted by a grid preset.
444    pub fn show(&mut self, ui: &mut Ui, charts: &mut [&mut Chart]) -> Response {
445        let total = charts.len().min(16);
446        let charts = &mut charts[..total];
447        let count = total.min(self.mode.chart_count());
448        if count == 0 {
449            return ui.allocate_response(Vec2::ZERO, Sense::hover());
450        }
451        self.active = self.active.min(count - 1);
452        if self.maximized.is_some_and(|index| index >= count) {
453            self.maximized = None;
454        }
455        if let Some(index) = self.maximized {
456            self.active = index;
457        }
458        if self.mode == ChartLayoutMode::Tabs {
459            egui::ScrollArea::horizontal().show(ui, |ui| {
460                ui.horizontal(|ui| {
461                    for (index, chart) in charts.iter().take(count).enumerate() {
462                        if ui
463                            .selectable_label(index == self.active, chart.symbol())
464                            .clicked()
465                        {
466                            self.active = index;
467                        }
468                    }
469                });
470            });
471        }
472        let (rect, response) = ui.allocate_exact_size(ui.available_size(), Sense::hover());
473        let rects = self.rects(rect, count);
474        ui.painter()
475            .rect_filled(rect, 0.0, ui.active_theme().ui.border);
476        // Select before drawing so keyboard and toolbar state follow pointer input this frame.
477        let wheel = super::pan_zoom::raw_wheel_delta(ui) != Vec2::ZERO;
478        if self.mode != ChartLayoutMode::Tabs
479            && let Some(index) = rects.iter().position(|r| {
480                ui.rect_contains_pointer(*r) && ui.input(|i| i.pointer.any_pressed() || wheel)
481            })
482        {
483            self.active = index;
484        }
485        self.sync_state(charts, total);
486        for (index, chart) in charts.iter().enumerate() {
487            if !self.sync.sync_time_axis || total == 1 {
488                self.pending_ranges[index] = None;
489            } else if self.previous[index]
490                .as_ref()
491                .is_some_and(|p| p.1 != chart.timeframe())
492            {
493                let own = self.previous_ranges[index].map(|range| PendingRange {
494                    range,
495                    source: None,
496                    bars: self.previous_bars[index],
497                });
498                self.pending_ranges[index] = self.pending_ranges[index].or(own);
499            }
500        }
501        for chart in charts.iter_mut() {
502            chart.last_hover_bar_idx = None;
503        }
504        let mut pointer_down = ui.input(|i| i.pointer.any_down());
505        let before_range = charts[self.active].visible_time_range();
506        let before_sync: Vec<_> = charts
507            .iter()
508            .map(|c| (c.synced_crosshair_bar_idx, c.get_time_scale_state()))
509            .collect();
510        for index in 0..count {
511            if self.mode == ChartLayoutMode::Tabs && index != self.active && !self.detached[index] {
512                continue;
513            }
514            if self.maximized.is_some_and(|slot| slot != index) && !self.detached[index] {
515                continue;
516            }
517            charts[index].layout_inactive =
518                count > 1 && index != self.active && !self.detached[index];
519            let focus = self.focus_active && index == self.active;
520            if self.detached[index] {
521                let id = egui::ViewportId::from_hash_of((ui.scope_id(), "detached_chart", index));
522                let chart = &mut charts[index];
523                let mut close = false;
524                let mut activate = false;
525                ui.ctx().show_viewport_immediate(
526                    id,
527                    egui::ViewportBuilder::default()
528                        .with_title(chart.symbol())
529                        .with_inner_size([900.0, 600.0]),
530                    |ui, _| {
531                        pointer_down |= ui.input(|i| i.pointer.any_down());
532                        close |= ui.input(|i| i.viewport().close_requested());
533                        if ui
534                            .button(l10n::text(ui.ctx(), "Return to layout"))
535                            .clicked()
536                        {
537                            close = true;
538                        }
539                        egui::CentralPanel::default().show(ui, |ui| {
540                            let response = chart.show(ui);
541                            if focus {
542                                response.request_focus();
543                            }
544                            activate |= response.is_pointer_button_down_on()
545                                || response.clicked()
546                                || (response.hovered()
547                                    && super::pan_zoom::raw_wheel_delta(ui) != Vec2::ZERO);
548                        });
549                    },
550                );
551                self.restore_time_range(index, chart);
552                self.detached[index] = !close;
553                if activate {
554                    self.active = index;
555                }
556                if (self.mode != ChartLayoutMode::Tabs || index == self.active)
557                    && self.maximized.is_none_or(|slot| slot == index)
558                {
559                    ui.scope_builder(egui::UiBuilder::new().max_rect(rects[index]), |ui| {
560                        if ui
561                            .button(l10n::text(ui.ctx(), "Return chart to layout"))
562                            .clicked()
563                        {
564                            self.detached[index] = false;
565                        }
566                    });
567                }
568                continue;
569            }
570            let rect = rects[index];
571            ui.painter()
572                .rect_filled(rect, 0.0, charts[index].config().background_color);
573            ui.scope_builder(
574                egui::UiBuilder::new()
575                    .max_rect(rect)
576                    .id_salt(("chart_slot", index)),
577                |ui| {
578                    ui.shrink_clip_rect(rect);
579                    let response = charts[index].show(ui);
580                    charts[index].layout_inactive = false;
581                    if focus {
582                        response.request_focus();
583                    }
584                    if index == self.active
585                        && (ui.rect_contains_pointer(rect) || egui::Popup::is_any_open(ui.ctx()))
586                        && charts[index]
587                            .pane_layout
588                            .maximized
589                            .is_none_or(|pane| pane == super::PaneId::MAIN)
590                    {
591                        let button = ui.put(
592                            Rect::from_min_size(
593                                rect.min
594                                    + Vec2::new(
595                                        (rect.width()
596                                            - if rect.width() < 600.0 { 22.0 } else { 100.0 })
597                                        .max(0.0),
598                                        2.0,
599                                    ),
600                                Vec2::splat(20.0),
601                            ),
602                            egui::Button::new("…").frame(false),
603                        );
604                        egui::Popup::menu(&button)
605                            .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
606                            .show(|ui| {
607                                if ui.button(l10n::text(ui.ctx(), "Detach chart")).clicked() {
608                                    self.detached[index] = true;
609                                    ui.close();
610                                }
611                                ui.label(l10n::text(ui.ctx(), "Sync group"));
612                                let mut group = self.groups[index].clone().unwrap_or_default();
613                                if ui.text_edit_singleline(&mut group).changed() {
614                                    self.groups[index] = (!group.is_empty()).then_some(group);
615                                }
616                            });
617                    }
618                },
619            );
620            self.restore_time_range(index, charts[index]);
621            if count > 1 && self.maximized.is_none() && index == self.active {
622                ui.painter().rect_stroke(
623                    rect,
624                    0.0,
625                    Stroke::new(2.0, ui.active_theme().ui.accent),
626                    StrokeKind::Inside,
627                );
628            }
629        }
630        self.focus_active = false;
631        // Alt+Enter toggles the focused tile in a grid; a lone chart leaves it to the host.
632        if self.mode != ChartLayoutMode::Tabs && count > 1 {
633            for (index, chart) in charts.iter_mut().take(count).enumerate() {
634                let requests = &mut chart.keyboard.requests;
635                let before = requests.len();
636                requests.retain(|request| {
637                    *request != ShortcutRequest::Action(KeyboardAction::MaximizeChart)
638                });
639                // A discarded pass replays its input, so only the last one toggles.
640                if requests.len() != before && !ui.ctx().will_discard() {
641                    self.maximized = if self.maximized == Some(index) {
642                        None
643                    } else {
644                        Some(index)
645                    };
646                    self.set_active(index);
647                    ui.ctx().request_repaint();
648                }
649            }
650        }
651        // Apply gestures from the source after it has handled this frame's input.
652        let sync_range = charts[self.active].visible_time_range() != before_range
653            || self.previous_preset != Some((self.mode, count));
654        self.sync_view(charts, total, sync_range);
655        self.previous_preset = Some((self.mode, count));
656        if charts
657            .iter()
658            .zip(before_sync)
659            .any(|(chart, (crosshair, (spacing, offset)))| {
660                chart.synced_crosshair_bar_idx != crosshair
661                    || (chart.get_time_scale_state().0 - spacing).abs() > 0.001
662                    || (chart.get_time_scale_state().1 - offset).abs() > 0.001
663            })
664        {
665            ui.ctx().request_repaint();
666        }
667        #[cfg(feature = "drawings")]
668        if self.sync.sync_drawings
669            && self
670                .drawings
671                .sync(charts, total, &self.groups, pointer_down)
672        {
673            ui.ctx().request_repaint();
674        }
675        #[cfg(feature = "drawings")]
676        if Chart::share_stored_drawings(charts) {
677            ui.ctx().request_repaint();
678        }
679        for (index, chart) in charts.iter().enumerate() {
680            self.previous[index] = Some((chart.symbol().to_owned(), chart.timeframe()));
681            self.previous_bars[index] = bars_key(chart);
682            if self.pending_ranges[index].is_none()
683                && let Some(range) = chart.visible_time_range()
684            {
685                self.previous_ranges[index] = Some(range);
686            }
687        }
688        response
689    }
690
691    fn restore_time_range(&mut self, index: usize, chart: &mut Chart) {
692        // Reloads can leave a chart empty for several frames, and a host without a feed replaces
693        // the old bars a frame or more after the switch; keep UTC edges until new bars arrive.
694        if let Some(pending) = self.pending_ranges[index]
695            && chart.visible_time_range().is_some()
696            && bars_key(chart) != pending.bars
697        {
698            // A range the new bars do not cover keeps the view, but must not block the group.
699            chart.apply_synced_time_range_from(pending.range, pending.source);
700            self.pending_ranges[index] = None;
701        }
702    }
703
704    fn sync_state(&mut self, charts: &mut [&mut Chart], count: usize) {
705        let source = &charts[self.active];
706        let symbol = source.symbol().to_owned();
707        let timeframe = source.timeframe();
708        let previous = self.previous[self.active].as_ref();
709        let symbol_changed = previous.is_some_and(|p| p.0 != symbol);
710        let timeframe_changed = previous.is_some_and(|p| p.1 != timeframe);
711        for (index, chart) in charts.iter_mut().take(count).enumerate() {
712            if index == self.active || !self.same_group(index, self.active) {
713                continue;
714            }
715            if self.sync.sync_symbol && symbol_changed {
716                chart.set_symbol(&symbol);
717            }
718            if self.sync.sync_timeframe
719                && timeframe_changed
720                && let Some(tf) = timeframe
721            {
722                chart.set_timeframe(tf);
723            }
724        }
725    }
726
727    fn sync_view(&mut self, charts: &mut [&mut Chart], count: usize, sync_range: bool) {
728        let hovered = charts
729            .iter()
730            .take(count)
731            .position(|c| c.hover_time().is_some());
732        let period = hovered.and_then(|source| charts[source].hover_period());
733        // The source's main-pane price, or `None` over volume and indicator panes.
734        let price = hovered.and_then(|source| charts[source].last_crosshair?.1);
735        let range = charts[self.active].visible_time_range();
736        let source = charts[self.active].sync_source();
737        for (index, chart) in charts.iter_mut().take(count).enumerate() {
738            chart.set_synced_crosshair(None, None);
739            if self.sync.sync_crosshair
740                && let Some(source) = hovered
741                && index != source
742                && self.same_group(index, source)
743            {
744                chart.set_synced_crosshair(period, price);
745            }
746            if index != self.active
747                && self.same_group(index, self.active)
748                && self.sync.sync_time_axis
749                && self.pending_ranges[self.active].is_none()
750                && sync_range
751                && let Some(range) = range
752            {
753                // A chart awaiting its new interval's bars fits the latest range once they arrive.
754                match &mut self.pending_ranges[index] {
755                    Some(pending) => {
756                        pending.range = range;
757                        pending.source = source;
758                    }
759                    None => {
760                        chart.apply_synced_time_range_from(range, source);
761                    }
762                }
763            }
764        }
765    }
766
767    /// The slot and rectangle of each visible chart inside `rect`, for an image export.
768    /// Uses the on-screen arrangement, omitting detached and hidden slots and tab controls.
769    /// `count` is the number of charts the host owns, including the active one.
770    pub fn visible_chart_rects(&self, rect: Rect, count: usize) -> Vec<(usize, Rect)> {
771        let count = count.min(16).min(self.mode.chart_count());
772        self.rects(rect, count)
773            .into_iter()
774            .enumerate()
775            .filter(|(slot, rect)| {
776                !self.is_detached(*slot)
777                    && rect.is_positive()
778                    && (self.mode != ChartLayoutMode::Tabs
779                        || *slot == self.active.min(count.saturating_sub(1)))
780            })
781            .collect()
782    }
783
784    fn rects(&self, rect: Rect, count: usize) -> Vec<Rect> {
785        if self.mode == ChartLayoutMode::Tabs {
786            return vec![rect; count];
787        }
788        if let Some(slot) = self.maximized {
789            return (0..count)
790                .map(|index| if index == slot { rect } else { Rect::NOTHING })
791                .collect();
792        }
793        let (by_rows, tracks) = match self.mode.tracks() {
794            Tracks::Rows(tracks) => (true, tracks),
795            Tracks::Columns(tracks) => (false, tracks),
796        };
797        // Fewer charts than the preset holds fill its first rows or columns; empty ones go.
798        let tracks: Vec<usize> = tracks
799            .iter()
800            .scan(count, |left, &cells| {
801                let cells = cells.min(*left);
802                *left -= cells;
803                (cells > 0).then_some(cells)
804            })
805            .collect();
806        let mut rects = Vec::with_capacity(count);
807        for (track, &cells) in tracks.iter().enumerate() {
808            for cell in 0..cells {
809                let (column, columns, row, rows) = if by_rows {
810                    (cell, cells, track, tracks.len())
811                } else {
812                    (track, tracks.len(), cell, cells)
813                };
814                let width = rect.width() / columns as f32;
815                let height = ((rect.height() - 2.0 * (rows - 1) as f32) / rows as f32).max(0.0);
816                rects.push(Rect::from_min_size(
817                    rect.min + Vec2::new(column as f32 * width, row as f32 * (height + 2.0)),
818                    Vec2::new(width, height),
819                ));
820            }
821        }
822        rects
823    }
824}
825
826#[cfg(test)]
827mod tests {
828    use super::*;
829    use crate::model::{Bar, BarData};
830    use chrono::{DateTime, Duration};
831    use egui::{Event, PointerButton, Pos2, RawInput, pos2, vec2};
832
833    fn chart(step: i64) -> Chart {
834        let start = DateTime::from_timestamp(1_700_000_000, 0).unwrap();
835        let mut chart = Chart::new(BarData::from_bars(
836            (0..100)
837                .map(|i| {
838                    Bar::new(
839                        start + Duration::seconds(i * step),
840                        10.0,
841                        11.0,
842                        9.0,
843                        10.0,
844                        1.0,
845                    )
846                })
847                .collect(),
848        ));
849        chart.set_symbol("SAMPLE");
850        chart.set_timeframe(Timeframe::Min1);
851        chart
852    }
853
854    /// Only the active and hovered charts expose their scale settings in a layout.
855    #[cfg(feature = "icons")]
856    #[test]
857    fn scale_settings_follow_layout_activity_and_hover() {
858        for (mode, maximized, hovered, expected) in [
859            (ChartLayoutMode::Single, None, false, 1),
860            (ChartLayoutMode::Four, None, false, 1),
861            (ChartLayoutMode::Four, None, true, 2),
862            (ChartLayoutMode::Tabs, None, false, 1),
863            (ChartLayoutMode::Four, Some(2), false, 1),
864        ] {
865            let ctx = egui::Context::default();
866            ctx.enable_accesskit();
867            let mut charts = [chart(60), chart(60), chart(60), chart(60)];
868            let mut layout = ChartLayout {
869                mode,
870                maximized,
871                ..Default::default()
872            };
873            for _ in 0..2 {
874                let mut output = ctx.run_ui(
875                    RawInput {
876                        screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 800.0))),
877                        events: if hovered {
878                            vec![Event::PointerMoved(pos2(900.0, 200.0))]
879                        } else {
880                            vec![Event::PointerGone]
881                        },
882                        ..Default::default()
883                    },
884                    |ui| {
885                        layout.show(ui, &mut charts.iter_mut().collect::<Vec<_>>());
886                    },
887                );
888                output.textures_delta.clear();
889                let gears = output
890                    .platform_output
891                    .accesskit_update
892                    .as_ref()
893                    .unwrap()
894                    .nodes
895                    .iter()
896                    .filter(|(_, node)| node.label() == Some("Scale settings"))
897                    .count();
898                assert_eq!(
899                    gears, expected,
900                    "{mode:?}, maximized={maximized:?}, hovered={hovered}"
901                );
902                output.drop_without_applying_deltas();
903            }
904        }
905    }
906
907    #[test]
908    fn default_syncs_time_axis_and_crosshair_but_not_drawings() {
909        let options = ChartSyncOptions::default();
910        assert!(options.sync_time_axis);
911        assert!(options.sync_crosshair);
912        assert!(!options.sync_drawings);
913    }
914
915    #[test]
916    fn presets_preserve_chart_state_and_route_input_to_visible_slots() {
917        let rect = Rect::from_min_size(pos2(54.0, 70.0), vec2(1386.0, 716.0));
918        let mut labels = std::collections::HashSet::new();
919        for &mode in ChartLayoutMode::all() {
920            assert!(labels.insert(mode.label()), "{mode:?}");
921            let layout = ChartLayout {
922                mode,
923                ..Default::default()
924            };
925            let rects = layout.rects(rect, mode.chart_count());
926            assert_eq!(rects.len(), mode.chart_count());
927            if mode == ChartLayoutMode::Tabs {
928                continue;
929            }
930            let area: f32 = rects.iter().map(Rect::area).sum();
931            // Each cell above the bottom edge leaves a two-point gap below it.
932            let gaps: f32 = rects
933                .iter()
934                .filter(|cell| cell.bottom() < rect.bottom() - 1.0)
935                .map(|cell| 2.0 * cell.width())
936                .sum();
937            assert!((area + gaps - rect.area()).abs() < 1.0, "{mode:?}");
938            for (index, cell) in rects.iter().enumerate() {
939                assert!(rect.contains_rect(*cell));
940                assert!(
941                    rects[..index]
942                        .iter()
943                        .all(|other| other.intersect(*cell).area() == 0.0)
944                );
945            }
946            if mode == ChartLayoutMode::Four {
947                assert_eq!(rects[0], Rect::from_min_size(rect.min, vec2(693.0, 357.0)));
948                assert_eq!(rects[2].top(), 429.0);
949                assert_eq!(rects[1].left(), 747.0);
950            }
951            if mode == ChartLayoutMode::ThreeLargeLeft {
952                assert_eq!(
953                    rects,
954                    [
955                        Rect::from_min_size(rect.min, vec2(693.0, 716.0)),
956                        Rect::from_min_size(pos2(747.0, 70.0), vec2(693.0, 357.0)),
957                        Rect::from_min_size(pos2(747.0, 429.0), vec2(693.0, 357.0)),
958                    ]
959                );
960            }
961        }
962        // Fewer charts than a preset holds fill its first rows, which share the full height.
963        let sixteen = ChartLayout {
964            mode: ChartLayoutMode::Sixteen,
965            ..Default::default()
966        };
967        assert_eq!(
968            sixteen.rects(rect, 6)[5],
969            Rect::from_min_size(pos2(747.0, 429.0), vec2(693.0, 357.0))
970        );
971        let ctx = egui::Context::default();
972        for width in [80.0, 240.0] {
973            let clip = Rect::from_min_size(Pos2::ZERO, vec2(width, 600.0));
974            let output = ctx.run_ui(RawInput::default(), |ui| {
975                ui.set_clip_rect(clip);
976                ChartLayout::default().show(ui, &mut [&mut chart(60)]);
977            });
978            let clipped = output.shapes.iter().all(|shape| {
979                !matches!(shape.shape, egui::Shape::Text(_)) || clip.contains_rect(shape.clip_rect)
980            });
981            output.drop_without_applying_deltas();
982            assert!(clipped, "a chart must respect its host's clip");
983        }
984        let mut charts = [chart(60), chart(120)];
985        for chart in &mut charts {
986            chart.set_symbol("LONG_SYMBOL_".repeat(12));
987        }
988        let mut layout = ChartLayout {
989            mode: ChartLayoutMode::Tabs,
990            ..Default::default()
991        };
992        layout.set_active(1);
993        let frame = |layout: &mut ChartLayout, charts: &mut [Chart; 2], events| {
994            let output = ctx.run_ui(
995                RawInput {
996                    screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(640.0, 900.0))),
997                    events,
998                    ..Default::default()
999                },
1000                |ui| {
1001                    layout.show(ui, &mut charts.iter_mut().collect::<Vec<_>>());
1002                    assert!(
1003                        ui.min_rect().right() <= 640.0,
1004                        "long chart tabs must scroll inside the container"
1005                    );
1006                },
1007            );
1008            let group = output.shapes.iter().find_map(|shape| match &shape.shape {
1009                egui::Shape::Text(text) if text.galley.text() == "Sync group" => {
1010                    Some(text.pos + text.galley.size() / 2.0)
1011                }
1012                _ => None,
1013            });
1014            output.drop_without_applying_deltas();
1015            group
1016        };
1017        frame(&mut layout, &mut charts, vec![]);
1018        let point = charts[1].get_rendered_price_rect().center();
1019        frame(
1020            &mut layout,
1021            &mut charts,
1022            vec![
1023                Event::PointerMoved(point),
1024                Event::PointerButton {
1025                    pos: point,
1026                    button: PointerButton::Primary,
1027                    pressed: true,
1028                    modifiers: Default::default(),
1029                },
1030            ],
1031        );
1032        assert_eq!(
1033            layout.active(),
1034            1,
1035            "clicking the tab's plot must not activate slot zero"
1036        );
1037        frame(
1038            &mut layout,
1039            &mut charts,
1040            vec![Event::PointerButton {
1041                pos: point,
1042                button: PointerButton::Primary,
1043                pressed: false,
1044                modifiers: Default::default(),
1045            }],
1046        );
1047        layout.mode = ChartLayoutMode::TwoHorizontal;
1048        frame(&mut layout, &mut charts, vec![]);
1049        let point = charts[0].get_rendered_price_rect().center();
1050        frame(
1051            &mut layout,
1052            &mut charts,
1053            vec![
1054                Event::PointerMoved(point),
1055                Event::PointerButton {
1056                    pos: point,
1057                    button: PointerButton::Primary,
1058                    pressed: true,
1059                    modifiers: Default::default(),
1060                },
1061            ],
1062        );
1063        assert_eq!(layout.active(), 0);
1064        frame(
1065            &mut layout,
1066            &mut charts,
1067            vec![Event::PointerButton {
1068                pos: point,
1069                button: PointerButton::Primary,
1070                pressed: false,
1071                modifiers: Default::default(),
1072            }],
1073        );
1074        // A group editor can overlap the next panel without activating that panel.
1075        let click = |pos, pressed| Event::PointerButton {
1076            pos,
1077            button: PointerButton::Primary,
1078            pressed,
1079            modifiers: Default::default(),
1080        };
1081        let corner = pos2(308.0, 12.0);
1082        frame(&mut layout, &mut charts, vec![Event::PointerMoved(corner)]);
1083        frame(&mut layout, &mut charts, vec![click(corner, true)]);
1084        frame(&mut layout, &mut charts, vec![click(corner, false)]);
1085        frame(&mut layout, &mut charts, vec![]);
1086        let input =
1087            frame(&mut layout, &mut charts, vec![]).expect("group editor") + vec2(100.0, 18.0);
1088        assert!(input.x > 320.0);
1089        frame(&mut layout, &mut charts, vec![Event::PointerMoved(input)]);
1090        frame(&mut layout, &mut charts, vec![click(input, true)]);
1091        frame(&mut layout, &mut charts, vec![click(input, false)]);
1092        frame(&mut layout, &mut charts, vec![Event::Text("Desk".into())]);
1093        assert_eq!(layout.sync_group(0), Some("Desk"));
1094        assert_eq!(layout.active(), 0);
1095        let escape = Event::Key {
1096            key: egui::Key::Escape,
1097            physical_key: None,
1098            pressed: true,
1099            repeat: false,
1100            modifiers: Default::default(),
1101        };
1102        frame(&mut layout, &mut charts, vec![escape]);
1103        layout.set_sync_group(0, None);
1104        frame(&mut layout, &mut charts, vec![Event::PointerMoved(point)]);
1105        frame(&mut layout, &mut charts, vec![click(point, true)]);
1106        frame(&mut layout, &mut charts, vec![click(point, false)]);
1107        layout.sync.sync_time_axis = false;
1108        for chart in &mut charts {
1109            chart.apply_synced_time_scale(3.0, 0.0);
1110        }
1111        #[cfg(feature = "drawings")]
1112        {
1113            // Delete applies only to the chart that received the pointer focus.
1114            for chart in &mut charts {
1115                let mut drawing = crate::drawings::Drawing::new(
1116                    0,
1117                    crate::drawings::DrawingToolType::HorizontalLine,
1118                );
1119                drawing.completed = true;
1120                drawing
1121                    .chart_points
1122                    .push(crate::drawings::ChartPoint::new(10.0, 10.0));
1123                chart.drawings_mut().add_synced_drawing(drawing).unwrap();
1124                let id = chart.drawings().drawings[0].id;
1125                chart.drawings_mut().select(id);
1126            }
1127            frame(
1128                &mut layout,
1129                &mut charts,
1130                vec![Event::Key {
1131                    key: egui::Key::Delete,
1132                    physical_key: None,
1133                    pressed: true,
1134                    repeat: false,
1135                    modifiers: Default::default(),
1136                }],
1137            );
1138            assert!(charts[0].drawings().drawings.is_empty());
1139            assert_eq!(charts[1].drawings().drawings.len(), 1);
1140            let id = charts[1].drawings().drawings[0].id;
1141            charts[1].drawings_mut().delete_drawing(id);
1142        }
1143
1144        // Hidden chart tabs must not remain crosshair sources.
1145        charts[0].last_hover_bar_idx = Some(10.0);
1146        layout.mode = ChartLayoutMode::Tabs;
1147        layout.set_active(1);
1148        frame(&mut layout, &mut charts, vec![Event::PointerGone]);
1149        assert_eq!(charts[0].hover_time(), None);
1150        assert_eq!(charts[1].synced_crosshair_bar_idx, None);
1151
1152        #[cfg(feature = "drawings")]
1153        {
1154            // Preset changes retain links, including edits and deletions while a slot is hidden.
1155            layout.mode = ChartLayoutMode::TwoHorizontal;
1156            layout.sync.sync_drawings = true;
1157            let mut drawing =
1158                crate::drawings::Drawing::new(0, crate::drawings::DrawingToolType::HorizontalLine);
1159            drawing.completed = true;
1160            drawing
1161                .chart_points
1162                .push(crate::drawings::ChartPoint::new(10.0, 10.0));
1163            charts[0]
1164                .drawings_mut()
1165                .add_synced_drawing(drawing)
1166                .unwrap();
1167            frame(&mut layout, &mut charts, vec![]);
1168            assert_eq!(charts[1].drawings().drawings.len(), 1);
1169            layout.mode = ChartLayoutMode::Single;
1170            frame(&mut layout, &mut charts, vec![]);
1171            layout.mode = ChartLayoutMode::TwoHorizontal;
1172            frame(&mut layout, &mut charts, vec![]);
1173            assert_eq!(charts[0].drawings().drawings.len(), 1);
1174            assert_eq!(charts[1].drawings().drawings.len(), 1);
1175            layout.mode = ChartLayoutMode::Single;
1176            let id = charts[0].drawings().drawings[0].id;
1177            charts[0].drawings_mut().delete_drawing(id);
1178            frame(&mut layout, &mut charts, vec![]);
1179            layout.mode = ChartLayoutMode::TwoHorizontal;
1180            frame(&mut layout, &mut charts, vec![]);
1181            assert!(charts[1].drawings().drawings.is_empty());
1182        }
1183
1184        layout.set_detached(1, true);
1185        frame(&mut layout, &mut charts, vec![]);
1186        assert_eq!(charts[1].bars(super::super::SeriesId::MAIN).len(), 100);
1187        layout.set_detached(1, false);
1188        frame(&mut layout, &mut charts, vec![]);
1189        assert!(!layout.is_detached(1));
1190    }
1191
1192    #[test]
1193    fn sync_switches_and_groups_isolate_changes() {
1194        let mut layout = ChartLayout {
1195            mode: ChartLayoutMode::Four,
1196            ..Default::default()
1197        };
1198        let mut charts = [chart(60), chart(120), chart(60), chart(60)];
1199        let mut refs: Vec<_> = charts.iter_mut().collect();
1200        layout.set_sync_group(2, Some("B".into()));
1201        layout.previous[0] = Some(("SAMPLE".into(), Some(Timeframe::Min1)));
1202        refs[0].set_symbol("OTHER");
1203        refs[0].set_timeframe(Timeframe::Hour1);
1204        layout.sync_state(&mut refs, 4);
1205        assert_eq!(refs[1].symbol(), "SAMPLE");
1206        layout.sync.sync_symbol = true;
1207        layout.sync.sync_timeframe = true;
1208        layout.sync_state(&mut refs, 4);
1209        assert_eq!(
1210            (refs[1].symbol(), refs[1].timeframe()),
1211            ("OTHER", Some(Timeframe::Hour1))
1212        );
1213        assert_eq!(
1214            (refs[2].symbol(), refs[2].timeframe()),
1215            ("SAMPLE", Some(Timeframe::Min1))
1216        );
1217        // The hovered chart emits crosshairs without becoming the selected chart.
1218        refs[1].last_hover_bar_idx = Some(2.0);
1219        let time = refs[1].hover_time().unwrap();
1220        refs[1].last_crosshair = Some((time, Some(10.5)));
1221        layout.sync_view(&mut refs, 4, false);
1222        assert_eq!(refs[0].synced_crosshair_bar_idx, Some(4.0));
1223        assert_eq!(refs[0].synced_crosshair_price, Some(10.5));
1224        assert_eq!(refs[2].synced_crosshair_bar_idx, None);
1225        refs[1].last_hover_bar_idx = None;
1226        layout.sync_view(&mut refs, 4, false);
1227        assert_eq!(refs[0].synced_crosshair_bar_idx, None);
1228        assert_eq!(refs[0].synced_crosshair_price, None);
1229        refs[1].last_hover_bar_idx = Some(2.0);
1230        layout.sync.sync_crosshair = false;
1231        layout.sync_view(&mut refs, 4, false);
1232        assert_eq!(refs[0].synced_crosshair_bar_idx, None);
1233        let range = super::super::VisibleTimeRange {
1234            from: refs[0].state.shown()[10].time,
1235            to: refs[0].state.shown()[30].time,
1236        };
1237        let before = refs[1].get_time_scale_state();
1238        layout.apply_date_range(&mut refs, range);
1239        assert_eq!(refs[1].get_time_scale_state(), before);
1240        layout.sync.sync_date_range = true;
1241        let isolated = refs[2].get_time_scale_state();
1242        layout.apply_date_range(&mut refs, range);
1243        assert_ne!(refs[1].get_time_scale_state(), before);
1244        assert_eq!(refs[2].get_time_scale_state(), isolated);
1245
1246        // A minute chart at the spacing floor must retain its UTC view after an hourly reload.
1247        let ctx = egui::Context::default();
1248        for mode in [ChartLayoutMode::Four, ChartLayoutMode::Tabs] {
1249            let mut layout = ChartLayout {
1250                mode,
1251                ..Default::default()
1252            };
1253            let mut charts = [chart(60), chart(60), chart(60), chart(60)];
1254            let frame = |layout: &mut ChartLayout, charts: &mut [Chart; 4]| {
1255                ctx.run_ui(
1256                    RawInput {
1257                        screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))),
1258                        ..Default::default()
1259                    },
1260                    |ui| {
1261                        layout.show(ui, &mut charts.iter_mut().collect::<Vec<_>>());
1262                    },
1263                )
1264                .drop_without_applying_deltas();
1265            };
1266            frame(&mut layout, &mut charts);
1267            for active in [0, 1] {
1268                layout.set_active(active);
1269                frame(&mut layout, &mut charts);
1270                charts[active].apply_synced_time_scale(0.5, 0.0);
1271                frame(&mut layout, &mut charts);
1272                let remembered = charts[active].visible_time_range().unwrap();
1273                charts[active].set_timeframe(Timeframe::Hour1);
1274                charts[active].set_data(super::super::SeriesId::MAIN, vec![]);
1275                frame(&mut layout, &mut charts);
1276                let others: Vec<_> = charts.iter().map(Chart::get_time_scale_state).collect();
1277                frame(&mut layout, &mut charts);
1278                for (index, chart) in charts.iter().enumerate() {
1279                    if index != active {
1280                        assert_eq!(chart.get_time_scale_state(), others[index]);
1281                    }
1282                }
1283                charts[active].set_data(
1284                    super::super::SeriesId::MAIN,
1285                    chart(3600).state.shown().to_vec(),
1286                );
1287                frame(&mut layout, &mut charts);
1288                // A 60x longer interval needs 60x the spacing for the same UTC span.
1289                assert!((charts[active].get_time_scale_state().0 - 30.0).abs() < 0.01);
1290                let actual = charts[active].visible_time_range().unwrap();
1291                assert!((actual.from - remembered.from).num_milliseconds().abs() < 1);
1292                assert!((actual.to - remembered.to).num_milliseconds().abs() < 1);
1293            }
1294            layout.set_active(0);
1295            charts[0].set_visible_bars(65);
1296            frame(&mut layout, &mut charts);
1297            for chart in &charts {
1298                let scale = chart.state.time_scale();
1299                let range = scale.visible_logical_range();
1300                assert!(scale.bar_spacing().is_finite() && scale.bar_spacing() >= 0.5);
1301                assert!(range.left.ceil().max(0.0) <= range.right.floor().min(99.0));
1302            }
1303            // Bars that miss the remembered range keep the view and must not block the group.
1304            layout.set_active(2);
1305            frame(&mut layout, &mut charts);
1306            charts[2].set_timeframe(Timeframe::Day1);
1307            charts[2].set_data(super::super::SeriesId::MAIN, vec![]);
1308            frame(&mut layout, &mut charts);
1309            let mut later = chart(86_400).state.shown().to_vec();
1310            later
1311                .iter_mut()
1312                .for_each(|bar| bar.time += Duration::days(3650));
1313            charts[2].set_data(super::super::SeriesId::MAIN, later);
1314            frame(&mut layout, &mut charts);
1315            assert!(layout.pending_ranges.iter().all(Option::is_none));
1316
1317            // A chart still loading a synced interval adopts the group's latest range, so its
1318            // arrival does not pull the group back to the range from before the switch, also
1319            // past 50 px per bar and when its old bars are replaced, not emptied, a frame later.
1320            for emptied in [true, false] {
1321                let mut layout = ChartLayout {
1322                    mode,
1323                    ..Default::default()
1324                };
1325                layout.sync.sync_timeframe = true;
1326                let mut charts = [chart(60), chart(60), chart(60), chart(60)];
1327                frame(&mut layout, &mut charts);
1328                charts[0].set_timeframe(Timeframe::Hour1);
1329                for chart in charts.iter_mut().filter(|_| emptied) {
1330                    chart.set_data(super::super::SeriesId::MAIN, vec![]);
1331                }
1332                frame(&mut layout, &mut charts);
1333                let hourly = chart(3600).state.shown().to_vec();
1334                charts[0].set_data(super::super::SeriesId::MAIN, hourly.clone());
1335                frame(&mut layout, &mut charts);
1336                charts[0].set_visible_bars(8);
1337                frame(&mut layout, &mut charts);
1338                assert!(charts[0].get_time_scale_state().0 > 50.0);
1339                let latest = charts[0].visible_time_range().unwrap();
1340                charts[1].set_data(super::super::SeriesId::MAIN, hourly);
1341                layout.set_active(1);
1342                frame(&mut layout, &mut charts);
1343                for chart in &charts[..2] {
1344                    let actual = chart.visible_time_range().unwrap();
1345                    assert!((actual.from - latest.from).num_milliseconds().abs() < 1);
1346                    assert!((actual.to - latest.to).num_milliseconds().abs() < 1);
1347                }
1348            }
1349
1350            // Charts of one interval zoom together past the cross-interval spacing cap.
1351            let mut layout = ChartLayout {
1352                mode,
1353                ..Default::default()
1354            };
1355            let mut charts = [chart(60), chart(60), chart(60), chart(60)];
1356            frame(&mut layout, &mut charts);
1357            charts[0].set_visible_bars(8);
1358            frame(&mut layout, &mut charts);
1359            assert!(charts[0].get_time_scale_state().0 > 72.0);
1360            let source = charts[0].visible_time_range().unwrap();
1361            for chart in &charts[1..] {
1362                let actual = chart.visible_time_range().unwrap();
1363                assert!((actual.from - source.from).num_milliseconds().abs() < 1);
1364                assert!((actual.to - source.to).num_milliseconds().abs() < 1);
1365            }
1366        }
1367    }
1368
1369    /// Alt+Enter shows the focused tile alone and a second press brings the preset back with the
1370    /// tile's slot, viewport and synchronization group unchanged.
1371    #[test]
1372    fn alt_enter_maximizes_and_restores_the_active_tile() {
1373        let ctx = egui::Context::default();
1374        let mut layout = ChartLayout {
1375            mode: ChartLayoutMode::Four,
1376            ..Default::default()
1377        };
1378        let mut charts = [chart(60), chart(120), chart(60), chart(60)];
1379        layout.set_sync_group(2, Some("B".into()));
1380        layout.set_active(2);
1381        let mut time = 0.0;
1382        let mut frame = |layout: &mut ChartLayout, charts: &mut [Chart; 4], pressed: bool| {
1383            time += 0.1;
1384            let events = if pressed {
1385                vec![Event::Key {
1386                    key: egui::Key::Enter,
1387                    physical_key: None,
1388                    pressed: true,
1389                    repeat: false,
1390                    modifiers: egui::Modifiers::ALT,
1391                }]
1392            } else {
1393                vec![]
1394            };
1395            ctx.run_ui(
1396                RawInput {
1397                    screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))),
1398                    time: Some(time),
1399                    events,
1400                    ..Default::default()
1401                },
1402                |ui| {
1403                    layout.show(ui, &mut charts.iter_mut().collect::<Vec<_>>());
1404                },
1405            )
1406            .drop_without_applying_deltas();
1407        };
1408        for _ in 0..3 {
1409            frame(&mut layout, &mut charts, false);
1410        }
1411        let tile = charts[2].get_rendered_price_rect();
1412        let viewport = charts[2].get_time_scale_state();
1413        frame(&mut layout, &mut charts, true);
1414        // The chart's widget id follows its rect, so focus moves over to it on the next frames.
1415        frame(&mut layout, &mut charts, false);
1416        frame(&mut layout, &mut charts, false);
1417        assert_eq!((layout.maximized, layout.active()), (Some(2), 2));
1418        assert!(charts[2].get_rendered_price_rect().width() > tile.width() * 1.8);
1419        assert_eq!(layout.sync_group(2), Some("B"));
1420        assert!(charts[2].take_shortcut_requests().is_empty());
1421        frame(&mut layout, &mut charts, true);
1422        frame(&mut layout, &mut charts, false);
1423        frame(&mut layout, &mut charts, false);
1424        assert_eq!((layout.maximized, layout.active()), (None, 2));
1425        assert_eq!(charts[2].get_rendered_price_rect(), tile);
1426        assert_eq!(charts[2].get_time_scale_state(), viewport);
1427    }
1428}