egui_charts/ui/top_toolbar/
host.rs1use 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
10pub struct ToolbarEntry {
25 pub id: String,
27 pub label: String,
29 pub icon: Option<&'static Icon>,
31 pub items: Vec<ToolbarEntry>,
34 pub visible: bool,
36}
37
38impl ToolbarEntry {
39 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 pub fn with_icon(mut self, icon: &'static Icon) -> Self {
52 self.icon = Some(icon);
53 self
54 }
55
56 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 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}