Skip to main content

egui_charts/ui/top_toolbar/components/
chart_grid_selector.rs

1//! Grid presets and synchronization switches for a chart layout.
2
3use crate::icons::{Icon, icons};
4use crate::localization as l10n;
5use crate::theme::ThemeExt;
6use crate::ui::model::ChartLayoutMode;
7use crate::ui::top_toolbar::TopToolbarConfig;
8use crate::ui::top_toolbar::buttons::ToolbarIconButton;
9use crate::widget::ChartSyncOptions;
10use egui::{RichText, Ui, Vec2};
11
12/// Width of the open menu; a smaller window narrows it.
13const MENU_WIDTH: f32 = 428.0;
14/// An arrangement tile. Its glyph is the 24-unit layout artwork drawn at 27 points, so the frame
15/// spans about 19 by 16 points.
16const TILE: Vec2 = Vec2::new(29.0, 27.0);
17
18/// A layout trigger with an anchored, scrollable preset menu.
19#[derive(Default)]
20pub struct ChartGridSelector {
21    current: ChartLayoutMode,
22    /// Synchronization options chosen by the user. Apply these to the layout each frame.
23    pub sync: ChartSyncOptions,
24}
25
26impl ChartGridSelector {
27    /// Creates a selector for the initial preset.
28    pub fn new(initial: ChartLayoutMode) -> Self {
29        Self {
30            current: initial,
31            ..Default::default()
32        }
33    }
34
35    /// Selected preset.
36    pub fn current(&self) -> ChartLayoutMode {
37        self.current
38    }
39
40    /// Updates the selection from host state.
41    pub fn set_layout(&mut self, layout: ChartLayoutMode) {
42        self.current = layout;
43    }
44
45    /// Shows the trigger and returns a changed grid or tab preset.
46    pub fn show(&mut self, ui: &mut Ui) -> Option<ChartLayoutMode> {
47        // Sized and labelled like the toolbar buttons beside it, with their tooltip.
48        let button = ToolbarIconButton::new(
49            layout_icon(self.current),
50            "Chart layout",
51            &TopToolbarConfig::default(),
52        )
53        .show(ui);
54        let mut selected = None;
55        egui::Popup::menu(&button)
56            .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
57            .show(|ui| {
58                selected = self.show_menu(ui);
59            });
60        selected
61    }
62
63    /// Every arrangement, in one row per chart count, and the synchronization switches.
64    pub fn show_menu(&mut self, ui: &mut Ui) -> Option<ChartLayoutMode> {
65        let theme = ui.active_theme();
66        ui.set_width(
67            MENU_WIDTH
68                .min(ui.ctx().content_rect().width() - 24.0)
69                .max(0.0),
70        );
71        // The menu scrolls only when the window is too short to hold it below the trigger.
72        let max_height = ui.ctx().content_rect().bottom()
73            - ui.next_widget_position().y
74            - ui.spacing().menu_margin.sum().y
75            - theme.spacing.lg;
76        ui.set_max_height(max_height);
77        let mut selected = None;
78        egui::ScrollArea::vertical()
79            .max_height(max_height)
80            .show(ui, |ui| {
81                ui.spacing_mut().item_spacing = Vec2::new(theme.spacing.sm, 0.0);
82                for count in 1..=16 {
83                    if count > 1 {
84                        ui.add(egui::Separator::default().spacing(1.0));
85                    }
86                    // Tiles wrap onto a second line in a window too narrow for the row.
87                    let row = egui::Layout::left_to_right(egui::Align::Center).with_main_wrap(true);
88                    ui.allocate_ui_with_layout(Vec2::new(ui.available_width(), 35.0), row, |ui| {
89                        ui.add_sized(
90                            Vec2::new(32.0, TILE.y),
91                            egui::Label::new(
92                                RichText::new(count.to_string())
93                                    .size(theme.text.small)
94                                    .color(theme.ui.text_muted),
95                            ),
96                        );
97                        for &layout in ChartLayoutMode::all()
98                            .iter()
99                            .filter(|layout| layout.chart_count() == count)
100                        {
101                            if arrangement(ui, layout, self.current == layout).clicked() {
102                                self.current = layout;
103                                selected = Some(layout);
104                                ui.close();
105                            }
106                        }
107                    });
108                }
109                ui.add(egui::Separator::default().spacing(theme.spacing.lg));
110                ui.horizontal(|ui| {
111                    ui.add_space(theme.spacing.lg);
112                    ui.label(
113                        RichText::new(
114                            l10n::text(ui.ctx(), "Synchronize within group").to_uppercase(),
115                        )
116                        .size(theme.text.small)
117                        .color(theme.ui.text_muted),
118                    );
119                });
120                ui.add_space(theme.spacing.sm);
121                for (value, label, effect) in [
122                    (
123                        &mut self.sync.sync_symbol,
124                        "Symbol",
125                        "A symbol chosen on one chart applies to every chart of the group.",
126                    ),
127                    (
128                        &mut self.sync.sync_timeframe,
129                        "Interval",
130                        "An interval chosen on one chart applies to every chart of the group.",
131                    ),
132                    (
133                        &mut self.sync.sync_crosshair,
134                        "Crosshair",
135                        "Every chart of the group marks the time hovered on one of them.",
136                    ),
137                    (
138                        &mut self.sync.sync_time_axis,
139                        "Scroll and zoom",
140                        "Scrolling or zooming one chart shows the same time span on every chart of the group.",
141                    ),
142                    (
143                        &mut self.sync.sync_date_range,
144                        "Date range",
145                        "A date range chosen on one chart applies to every chart of the group.",
146                    ),
147                    (
148                        &mut self.sync.sync_drawings,
149                        "Drawings on the same symbol",
150                        "A drawing completed on one chart appears on the charts of the group showing the same symbol.",
151                    ),
152                ] {
153                    sync_switch(ui, value, label, effect);
154                }
155            });
156        selected
157    }
158}
159
160/// One arrangement: a tile filled with the text colour, and its glyph in the panel colour, when it
161/// is the current preset.
162fn arrangement(ui: &mut Ui, layout: ChartLayoutMode, chosen: bool) -> egui::Response {
163    let theme = ui.active_theme();
164    let (rect, response) = ui.allocate_exact_size(TILE, egui::Sense::click());
165    let fill = if chosen {
166        theme.ui.text
167    } else if response.is_pointer_button_down_on() {
168        ui.visuals().widgets.active.bg_fill
169    } else if response.hovered() {
170        ui.visuals().widgets.hovered.bg_fill
171    } else {
172        egui::Color32::TRANSPARENT
173    };
174    ui.painter().rect_filled(rect, theme.radius.sm, fill);
175    let glyph = egui::Rect::from_center_size(rect.center(), Vec2::splat(27.0));
176    let ink = if chosen {
177        theme.ui.panel_bg
178    } else {
179        theme.ui.text
180    };
181    layout_icon(layout)
182        .as_image_tinted(glyph.size(), ink)
183        .paint_at(ui, glyph);
184    crate::styles::focus::draw_focus_ring(ui, &response, theme.radius.sm);
185    if response.has_focus() {
186        response.scroll_to_me(None);
187    }
188    let name = l10n::text(ui.ctx(), layout.label());
189    response.widget_info(|| {
190        egui::WidgetInfo::selected(egui::Role::MenuItemRadio, response.enabled(), chosen, &name)
191    });
192    response.on_hover_text(name)
193}
194
195/// A synchronization option. Clicking the row switches it in place. The information glyph explains
196/// the option on hover; the explanation is also the switch's accessible description.
197fn sync_switch(ui: &mut Ui, on: &mut bool, label: &str, effect: &str) {
198    let theme = ui.active_theme();
199    let (row, mut response) =
200        ui.allocate_exact_size(Vec2::new(ui.available_width(), 32.0), egui::Sense::click());
201    if response.clicked() {
202        *on = !*on;
203        response.mark_changed();
204    }
205    let name = l10n::text(ui.ctx(), label);
206    let effect = l10n::text(ui.ctx(), effect);
207    response.widget_info(|| {
208        egui::WidgetInfo::selected(egui::Role::Switch, response.enabled(), *on, &name)
209    });
210    ui.ctx()
211        .accesskit_node_builder(response.id, |node| node.set_description(effect.as_str()));
212    let switch = egui::Rect::from_center_size(
213        row.right_center() - Vec2::new(theme.spacing.lg + 19.0, 0.0),
214        Vec2::new(38.0, 20.0),
215    );
216    // A long translation is cut short before the glyph and the switch; its name stays whole.
217    let room = switch.left() - row.left() - 2.0 * theme.spacing.lg - theme.spacing.sm - 14.0;
218    let text = egui::WidgetText::from(name.as_str()).into_galley(
219        ui,
220        Some(egui::TextWrapMode::Truncate),
221        room.max(0.0),
222        egui::TextStyle::Body,
223    );
224    let text_rect = egui::Align2::LEFT_CENTER.anchor_size(
225        row.left_center() + Vec2::new(theme.spacing.lg, 0.0),
226        text.size(),
227    );
228    ui.painter().galley(text_rect.min, text, theme.ui.text);
229    let info = egui::Rect::from_min_size(
230        egui::pos2(text_rect.right() + theme.spacing.sm, row.center().y - 7.0),
231        Vec2::splat(14.0),
232    );
233    icons::INFO
234        .as_image_tinted(info.size(), theme.ui.text_muted)
235        .paint_at(ui, info);
236    // The glyph takes its own clicks, so asking for the explanation leaves the option as it is.
237    ui.interact(info, response.id.with("info"), egui::Sense::CLICK)
238        .on_hover_text(effect);
239    let track = if *on {
240        theme.ui.text
241    } else {
242        theme.ui.border_control
243    };
244    ui.painter().rect_filled(switch, 10.0, track);
245    let knob = if *on {
246        switch.right() - 10.0
247    } else {
248        switch.left() + 10.0
249    };
250    ui.painter()
251        .circle_filled(egui::pos2(knob, switch.center().y), 7.0, theme.ui.panel_bg);
252    if response.has_focus() {
253        // Inside the row, as in the cursor flyout, because the scroll area clips outside rings.
254        ui.painter().rect_stroke(
255            row.shrink(2.0),
256            theme.radius.md,
257            egui::Stroke::new(2.0, crate::styles::focus::focus_ring_color(ui)),
258            egui::StrokeKind::Inside,
259        );
260        response.scroll_to_me(None);
261    }
262}
263
264/// The glyph of `layout` on the trigger and in the menu: its cells, as the layout tiles the charts.
265fn layout_icon(layout: ChartLayoutMode) -> &'static Icon {
266    use ChartLayoutMode as M;
267    match layout {
268        M::Single => &icons::LAYOUT_SINGLE,
269        M::TwoHorizontal => &icons::LAYOUT_TWO_HORIZONTAL,
270        M::TwoVertical => &icons::LAYOUT_TWO_VERTICAL,
271        M::Three => &icons::LAYOUT_THREE,
272        M::ThreeHorizontal => &icons::LAYOUT_THREE_HORIZONTAL,
273        M::ThreeVertical => &icons::LAYOUT_THREE_VERTICAL,
274        M::ThreeLargeLeft => &icons::LAYOUT_THREE_LARGE_LEFT,
275        M::ThreeLargeRight => &icons::LAYOUT_THREE_LARGE_RIGHT,
276        M::ThreeLargeTop => &icons::LAYOUT_THREE_LARGE_TOP,
277        M::Four => &icons::LAYOUT_FOUR,
278        M::FourHorizontal => &icons::LAYOUT_FOUR_HORIZONTAL,
279        M::FourVertical => &icons::LAYOUT_FOUR_VERTICAL,
280        M::FourLargeLeft => &icons::LAYOUT_FOUR_LARGE_LEFT,
281        M::FourLargeRight => &icons::LAYOUT_FOUR_LARGE_RIGHT,
282        M::FourLargeTop => &icons::LAYOUT_FOUR_LARGE_TOP,
283        M::FourLargeBottom => &icons::LAYOUT_FOUR_LARGE_BOTTOM,
284        M::Five => &icons::LAYOUT_FIVE,
285        M::FiveHorizontal => &icons::LAYOUT_FIVE_HORIZONTAL,
286        M::FiveVertical => &icons::LAYOUT_FIVE_VERTICAL,
287        M::FiveLargeLeft => &icons::LAYOUT_FIVE_LARGE_LEFT,
288        M::FiveLargeRight => &icons::LAYOUT_FIVE_LARGE_RIGHT,
289        M::FiveLargeTop => &icons::LAYOUT_FIVE_LARGE_TOP,
290        M::FiveLargeBottom => &icons::LAYOUT_FIVE_LARGE_BOTTOM,
291        M::Six => &icons::LAYOUT_SIX,
292        M::SixHorizontal => &icons::LAYOUT_SIX_HORIZONTAL,
293        M::SixVertical => &icons::LAYOUT_SIX_VERTICAL,
294        M::SixLargeLeft => &icons::LAYOUT_SIX_LARGE_LEFT,
295        M::SixLargeRight => &icons::LAYOUT_SIX_LARGE_RIGHT,
296        M::SixLargeTop => &icons::LAYOUT_SIX_LARGE_TOP,
297        M::SixLargeBottom => &icons::LAYOUT_SIX_LARGE_BOTTOM,
298        M::Seven => &icons::LAYOUT_SEVEN,
299        M::Eight => &icons::LAYOUT_EIGHT,
300        M::Nine => &icons::LAYOUT_NINE,
301        M::Ten => &icons::LAYOUT_TEN,
302        M::Eleven => &icons::LAYOUT_ELEVEN,
303        M::Twelve => &icons::LAYOUT_TWELVE,
304        M::Thirteen => &icons::LAYOUT_THIRTEEN,
305        M::Fourteen => &icons::LAYOUT_FOURTEEN,
306        M::Fifteen => &icons::LAYOUT_FIFTEEN,
307        M::Sixteen => &icons::LAYOUT_SIXTEEN,
308        M::Tabs => &icons::LAYOUT_TABS,
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use egui::Role;
316    use egui_kittest::{
317        Harness,
318        kittest::{NodeT, Queryable},
319    };
320
321    /// Clicking a synchronization switch changes that option alone, and the menu stays open on the
322    /// switch's new state.
323    #[test]
324    fn sync_switch_toggles_its_option_in_place() {
325        let mut harness = Harness::builder()
326            .with_size(egui::vec2(1440.0, 900.0))
327            .with_context_setup(|ctx| {
328                egui_extras::install_image_loaders(ctx);
329                crate::theme::apply_to_egui(ctx, &crate::theme::Theme::light());
330            })
331            .build_ui_state(
332                |ui, selector: &mut ChartGridSelector| {
333                    selector.show(ui);
334                },
335                ChartGridSelector::default(),
336            );
337        harness
338            .get_by_role_and_label(Role::Button, "Chart layout")
339            .click();
340        harness.run();
341        harness
342            .get_by_role_and_label(Role::Switch, "Crosshair")
343            .click();
344        harness.run();
345        let expected = ChartSyncOptions {
346            sync_crosshair: false,
347            ..Default::default()
348        };
349        assert_eq!(harness.state().sync, expected);
350        assert_eq!(
351            harness
352                .get_by_role_and_label(Role::Switch, "Crosshair")
353                .accesskit_node()
354                .data()
355                .toggled(),
356            Some(egui::accesskit::Toggled::False)
357        );
358    }
359}