Skip to main content

egui_charts/ui/top_toolbar/
host.rs

1//! Host buttons and dropdown entries rendered alongside the built-in toolbar controls.
2
3use crate::icons::Icon;
4use crate::ui_kit::buttons::Button;
5use crate::ui_kit::list_item::{LabelContent, ListItem};
6use egui::Ui;
7
8use super::TopToolbarAction;
9
10/// A host button, or a dropdown when [`Self::items`] is nonempty.
11///
12/// Entries retain registration order and use the active theme. Ids must be unique within
13/// the toolbar, including dropdown children. The host handles [`TopToolbarAction::Host`].
14///
15/// ```
16/// use egui_charts::ui::top_toolbar::{TopToolbar, ToolbarEntry};
17/// let mut toolbar = TopToolbar::new();
18/// toolbar.host_entries.push(ToolbarEntry::new("research", "Research"));
19/// toolbar.host_entries.push(ToolbarEntry::new("tools", "Host tools").with_items(vec![
20///     ToolbarEntry::new("ticket", "Order ticket"),
21/// ]));
22/// // toolbar.show_with_state(ui, app_state) returns TopToolbarAction::Host(id).
23/// ```
24pub struct ToolbarEntry {
25    /// Stable id returned when this entry is chosen.
26    pub id: String,
27    /// Button or menu label.
28    pub label: String,
29    /// Optional icon using the built-in size and tint.
30    pub icon: Option<&'static Icon>,
31    /// Dropdown choices in display order. Only top-level entries open dropdowns.
32    /// An empty list makes this entry a button.
33    pub items: Vec<ToolbarEntry>,
34    /// Whether to show this entry. Hidden entries take no space and emit no actions.
35    pub visible: bool,
36}
37
38impl ToolbarEntry {
39    /// Create a visible host button.
40    pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
41        Self {
42            id: id.into(),
43            label: label.into(),
44            icon: None,
45            items: Vec::new(),
46            visible: true,
47        }
48    }
49
50    /// Paint an icon beside the label.
51    pub fn with_icon(mut self, icon: &'static Icon) -> Self {
52        self.icon = Some(icon);
53        self
54    }
55
56    /// Turn this button into a dropdown. Opening it emits no action.
57    pub fn with_items(mut self, items: Vec<Self>) -> Self {
58        self.items = items;
59        self
60    }
61
62    pub(super) fn show(&self, ui: &mut Ui, action: &mut TopToolbarAction) {
63        if !self.visible {
64            return;
65        }
66        ui.push_id(&self.id, |ui| {
67            let response = match self.icon {
68                Some(icon) => Button::icon_and_text(icon, &self.label),
69                None => Button::new(&self.label),
70            }
71            .show(ui);
72            if self.items.is_empty() {
73                if response.clicked() {
74                    *action = TopToolbarAction::Host(self.id.clone());
75                }
76            } else {
77                egui::Popup::menu(&response).show(|ui| {
78                    ui.set_width(crate::tokens::DESIGN_TOKENS.sizing.dialog.submenu_width);
79                    for item in self.items.iter().filter(|item| item.visible) {
80                        ui.push_id(&item.id, |ui| {
81                            let mut content = LabelContent::new(&item.label);
82                            if let Some(icon) = item.icon {
83                                content = content.with_icon(icon);
84                            }
85                            if ListItem::new().show(ui, content).clicked() {
86                                *action = TopToolbarAction::Host(item.id.clone());
87                                ui.close();
88                            }
89                        });
90                    }
91                });
92            }
93        });
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use crate::ui::app_state::ChartAppState;
101    use crate::ui::top_toolbar::TopToolbar;
102    use egui::{Event, PointerButton, Pos2, RawInput, Rect, Shape, Vec2};
103
104    #[test]
105    fn registered_buttons_and_dropdowns_emit_ids_in_both_themes() {
106        struct State;
107        impl ChartAppState for State {
108            fn active_symbol(&self) -> &str {
109                "SAMPLE"
110            }
111            fn active_timeframe(&self) -> &crate::model::Timeframe {
112                &crate::model::Timeframe::Min1
113            }
114            fn chart_type(&self) -> crate::model::ChartType {
115                crate::model::ChartType::Candles
116            }
117            fn is_connected(&self) -> bool {
118                true
119            }
120            fn is_replay_active(&self) -> bool {
121                false
122            }
123        }
124        for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
125            for width in [320.0, 600.0, 1440.0] {
126                let ctx = egui::Context::default();
127                crate::theme::apply_to_egui(&ctx, &theme);
128                let mut toolbar = TopToolbar::new();
129                toolbar.host_entries = vec![
130                    ToolbarEntry::new("research", "Research"),
131                    ToolbarEntry::new("tools", "Host tools")
132                        .with_items(vec![ToolbarEntry::new("copy", "Copy symbol")]),
133                    ToolbarEntry {
134                        visible: false,
135                        ..ToolbarEntry::new("hidden", "Hidden entry")
136                    },
137                ];
138                toolbar.call_to_action = Some(("cta".into(), "Get data".into()));
139                let mut frame = |events| {
140                    let mut action = TopToolbarAction::None;
141                    let output = ctx.run_ui(
142                        RawInput {
143                            screen_rect: Some(Rect::from_min_size(
144                                Pos2::ZERO,
145                                Vec2::new(width, 200.0),
146                            )),
147                            events,
148                            ..Default::default()
149                        },
150                        |ui| {
151                            action = toolbar.show_with_state(ui, &State);
152                        },
153                    );
154                    let labels = output
155                        .shapes
156                        .iter()
157                        .filter_map(|shape| match &shape.shape {
158                            Shape::Text(text) => Some((
159                                text.galley.text().to_owned(),
160                                text.pos + text.galley.size() / 2.0,
161                            )),
162                            _ => None,
163                        })
164                        .collect::<Vec<_>>();
165                    output.drop_without_applying_deltas();
166                    assert!(!labels.iter().any(|(label, _)| label == "Hidden entry"));
167                    (action, labels)
168                };
169                frame(vec![]);
170                let (_, labels) = frame(vec![]);
171                let at = |labels: &[(String, Pos2)], name: &str| {
172                    labels.iter().find(|(label, _)| label == name).unwrap().1
173                };
174                let interval = at(&labels, "D");
175                // Scroll to the end of the strip, where narrow windows place the host actions.
176                frame(vec![Event::PointerMoved(Pos2::new(150.0, interval.y))]);
177                frame(vec![Event::MouseWheel {
178                    unit: egui::MouseWheelUnit::Point,
179                    delta: Vec2::new(-2000.0, 0.0),
180                    phase: egui::TouchPhase::Move,
181                    source: egui::MouseWheelSource::Wheel,
182                    modifiers: Default::default(),
183                }]);
184                for _ in 0..30 {
185                    frame(vec![]);
186                }
187                let (_, labels) = frame(vec![]);
188                let research = at(&labels, "Research");
189                let tools = at(&labels, "Host tools");
190                let cta = at(&labels, "Get data");
191                assert!(
192                    (research.y - interval.y).abs() <= 1.0,
193                    "host buttons share the toolbar centre: {research:?} versus {interval:?}"
194                );
195                assert!(
196                    research.x < tools.x && tools.x < cta.x && cta.x < width,
197                    "registration order, the call to action last, and reserved width"
198                );
199                let click = |pos| {
200                    vec![
201                        Event::PointerMoved(pos),
202                        Event::PointerButton {
203                            pos,
204                            button: PointerButton::Primary,
205                            pressed: true,
206                            modifiers: Default::default(),
207                        },
208                        Event::PointerButton {
209                            pos,
210                            button: PointerButton::Primary,
211                            pressed: false,
212                            modifiers: Default::default(),
213                        },
214                    ]
215                };
216                frame(vec![Event::PointerMoved(research)]);
217                assert_eq!(
218                    frame(click(research)).0,
219                    TopToolbarAction::Host("research".into())
220                );
221                frame(vec![Event::PointerMoved(cta)]);
222                assert_eq!(frame(click(cta)).0, TopToolbarAction::Host("cta".into()));
223                frame(vec![Event::PointerMoved(tools)]);
224                assert_eq!(
225                    frame(click(tools)).0,
226                    TopToolbarAction::None,
227                    "opening is not choosing"
228                );
229                let (_, labels) = frame(vec![]);
230                let copy = at(&labels, "Copy symbol");
231                frame(vec![Event::PointerMoved(copy)]);
232                assert_eq!(frame(click(copy)).0, TopToolbarAction::Host("copy".into()));
233                assert_eq!(frame(vec![]).0, TopToolbarAction::None);
234            }
235        }
236    }
237}