1use 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
12const MENU_WIDTH: f32 = 428.0;
14const TILE: Vec2 = Vec2::new(29.0, 27.0);
17
18#[derive(Default)]
20pub struct ChartGridSelector {
21 current: ChartLayoutMode,
22 pub sync: ChartSyncOptions,
24}
25
26impl ChartGridSelector {
27 pub fn new(initial: ChartLayoutMode) -> Self {
29 Self {
30 current: initial,
31 ..Default::default()
32 }
33 }
34
35 pub fn current(&self) -> ChartLayoutMode {
37 self.current
38 }
39
40 pub fn set_layout(&mut self, layout: ChartLayoutMode) {
42 self.current = layout;
43 }
44
45 pub fn show(&mut self, ui: &mut Ui) -> Option<ChartLayoutMode> {
47 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 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 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 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
160fn 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
195fn 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 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 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 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
264fn 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 #[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}