Skip to main content

egui_charts/ui/drawing_toolbar/toolbar/
mod.rs

1//! Drawing Toolbar
2//!
3//! This is the main coordinator for the left sidebar toolbar.
4//! It delegates rendering to:
5//! - `components/` for reusable UI elements
6//! - `categories/` for tool category submenus
7//! - `utilities/` for bottom toolbar btns
8
9mod color_picker;
10mod floating;
11
12use super::categories::cursors::CursorType;
13use crate::drawings::DrawingToolType;
14use crate::icons::{Icon, icons as embedded_icons};
15use crate::localization as l10n;
16use crate::styles::responsive::LayoutContext;
17use crate::theme::ThemeExt;
18use crate::tokens::DESIGN_TOKENS;
19use crate::ui::Control;
20use crate::ui_kit::buttons::IconButton;
21use egui::{Id, Key, Modifiers, Popup, Rect, Sense, Ui, Vec2};
22use std::collections::HashSet;
23
24/// Data struct for passing cursor state to submenu via context
25#[derive(Clone, Copy, Debug)]
26pub struct CursorStateData {
27    pub current_cursor: CursorType,
28    pub eraser_mode: bool,
29    pub values_tooltip: bool,
30}
31
32impl CursorStateData {
33    /// Where the toolbar stores the state for the cursor menu.
34    pub(crate) fn id() -> Id {
35        Id::unique("drawing_toolbar_cursor_state")
36    }
37
38    /// Whether `cursor` is the mode in force: the eraser, or the cursor while the eraser is off.
39    pub(crate) fn is_active(&self, cursor: CursorType) -> bool {
40        match cursor {
41            CursorType::Eraser => self.eraser_mode,
42            _ => !self.eraser_mode && self.current_cursor == cursor,
43        }
44    }
45}
46
47/// Drawing and cursor choices shared by the host and drawing toolbar.
48///
49/// Copy host choices into the toolbar before showing it, then apply its returned
50/// actions to the chart and host state.
51#[derive(Clone, Debug)]
52pub struct DrawingToolbarDomainState {
53    /// The chart's active drawing tool, or `None` after construction finishes.
54    pub sel_tool: Option<DrawingToolType>,
55    /// Whether snapping is enabled.
56    pub magnet_mode: bool,
57    /// The snapping strength, retained while snapping is off.
58    pub magnet_type: MagnetType,
59    /// Whether magnet targets include visible study plots.
60    pub snap_to_indicators: bool,
61    /// The chart cursor used while the eraser is off.
62    pub current_cursor_type: CursorType,
63    /// Whether the chart is erasing drawings.
64    pub eraser_mode_enabled: bool,
65    /// Whether the active drawing tool remains selected after construction.
66    pub stay_in_drawing_mode: bool,
67    /// Default colour for new drawings, in straight RGBA.
68    pub drawing_color: [u8; 4],
69}
70
71// Import from modular components
72use super::{
73    DrawingToolShortcuts, DrawingToolbarAction, DrawingToolbarConfig,
74    categories::{CursorsCategory, ToolCategory, trend_lines::render_registered_tool_submenu},
75    components::{draw_separator_styled, draw_tool_button},
76    data, icons,
77    state::{MagnetType, ToolbarState},
78    submenu_builder::{flyout_id, show_flyout},
79    utilities::{
80        FavoritesButton, HideMenu, LockButton, MagnetButton, MeasureButton, RemoveMenu,
81        StayInDrawingButton, TemplatesButton, ZoomInButton, ZoomOutButton,
82    },
83};
84
85/// Drawing toolbar - main UI component
86#[derive(Clone, Default)]
87pub struct DrawingToolbar {
88    /// Built-in availability for the bar, flyouts and shortcuts. Clone the chart manager's
89    /// registry here after changing it; hosts use its custom catalogue for their own picker.
90    pub registry: crate::drawings::DrawingToolRegistry,
91    /// Toolbar state (selected tool, favorites, recent, etc.)
92    pub state: ToolbarState,
93    /// Configuration (width, spacing, colors, etc.)
94    pub config: DrawingToolbarConfig,
95    /// Pos of the expanded category button (for submenu alignment)
96    expanded_category_rect: Option<Rect>,
97    /// Controls the host hid.
98    hidden: HashSet<Control>,
99    /// The egui pass the favourites strip was last drawn in.
100    strip_pass: Option<u64>,
101    /// The user switched the magnet on from the toolbar, so its card may show.
102    magnet_hint_open: bool,
103    /// The magnet button's rect in the last pass, while it is in view.
104    magnet_rect: Option<Rect>,
105}
106
107impl DrawingToolbar {
108    /// Create a [`DrawingToolbar`] with default settings.
109    pub fn new() -> Self {
110        Self::default()
111    }
112
113    /// Create with custom configuration
114    pub fn with_config(mut self, config: DrawingToolbarConfig) -> Self {
115        self.config = config;
116        self
117    }
118
119    /// Shows or hides `control`: Trading (the hide positions and orders rows) and Templates
120    /// (the standalone template name dialog). The toolbar ignores the other controls.
121    pub fn set_control_visible(&mut self, control: Control, visible: bool) {
122        crate::ui::app_state::set_control_visible(&mut self.hidden, control, visible);
123        if control == Control::Templates && !visible {
124            self.state.show_template_name_input = false;
125        }
126    }
127
128    // === State Sync Methods ===
129
130    /// Copy the host's drawing and cursor choices before [`Self::show_with_action`].
131    pub fn sync_from_domain_state(&mut self, domain: &DrawingToolbarDomainState) {
132        if domain.sel_tool.is_some()
133            || domain.eraser_mode_enabled
134            || domain.current_cursor_type != self.state.current_cursor_type
135        {
136            self.state.zoom_mode_active = false;
137        }
138        self.state.sel_tool = domain.sel_tool;
139        self.state.magnet_mode = domain.magnet_mode;
140        self.state.magnet_type = domain.magnet_type;
141        self.state.snap_to_indicators = domain.snap_to_indicators;
142        self.state.current_cursor_type = domain.current_cursor_type;
143        self.state.eraser_mode_enabled = domain.eraser_mode_enabled;
144        self.state.stay_in_drawing_mode = domain.stay_in_drawing_mode;
145        self.state.drawing_color = domain.drawing_color;
146    }
147
148    /// Read the toolbar's drawing and cursor choices for synchronization with the host.
149    pub fn get_domain_state(&self) -> DrawingToolbarDomainState {
150        DrawingToolbarDomainState {
151            sel_tool: self.state.sel_tool,
152            magnet_mode: self.state.magnet_mode,
153            magnet_type: self.state.magnet_type,
154            snap_to_indicators: self.state.snap_to_indicators,
155            current_cursor_type: self.state.current_cursor_type,
156            eraser_mode_enabled: self.state.eraser_mode_enabled,
157            stay_in_drawing_mode: self.state.stay_in_drawing_mode,
158            drawing_color: self.state.drawing_color,
159        }
160    }
161
162    // === State management methods ===
163
164    /// Select the tool if its registry entry is enabled.
165    pub fn select_tool(&mut self, tool: DrawingToolType) {
166        if self.registry.availability(tool).enabled {
167            self.state.select_tool(tool);
168        }
169    }
170
171    /// Record a tool in the recent selection list.
172    pub fn add_to_recent(&mut self, tool: DrawingToolType) {
173        self.state.add_to_recent(tool);
174    }
175
176    /// Clear the toolbar's selected tool.
177    pub fn clear_selection(&mut self) {
178        self.state.clear_selection();
179    }
180
181    /// Toggle a tool's membership in favorites.
182    pub fn toggle_favorite(&mut self, tool: DrawingToolType) {
183        self.state.toggle_favorite(tool);
184    }
185
186    /// Whether the tool is marked as a favorite.
187    pub fn is_favorite(&self, tool: &DrawingToolType) -> bool {
188        self.state.is_favorite(tool)
189    }
190
191    /// Toggle the toolbar's magnet setting.
192    pub fn toggle_magnet(&mut self) {
193        self.state.toggle_magnet();
194    }
195
196    /// Toggle whether the selected tool stays active after completing a drawing.
197    pub fn toggle_stay_in_drawing(&mut self) {
198        self.state.toggle_stay_in_drawing();
199    }
200
201    /// Render the toolbar as a side panel and return the selected tool if changed
202    pub fn show(&mut self, ui: &mut Ui) -> Option<DrawingToolType> {
203        let action = self.show_with_action(ui);
204        match action {
205            DrawingToolbarAction::SelectTool(tool) => Some(tool),
206            _ => None,
207        }
208    }
209
210    /// Render the toolbar and return the full action
211    pub fn show_with_action(&mut self, ui: &mut Ui) -> DrawingToolbarAction {
212        let mut action = DrawingToolbarAction::None;
213
214        // A tool's shortcut selects it, unless the user is typing.
215        if !crate::widget::keyboard::shortcuts_suppressed(ui.ctx())
216            && !ui.ctx().data(|data| {
217                data.get_temp::<crate::widget::ShortcutMap>(Id::NULL.with("chart_shortcuts"))
218                    .is_some()
219            })
220            && !Popup::is_any_open(ui.ctx())
221            && !ui.ctx().text_edit_focused()
222            && let Some(tool) = DrawingToolShortcuts::default().check(ui.ctx())
223            && self.registry.availability(tool).enabled
224        {
225            self.select_tool(tool);
226            action = DrawingToolbarAction::SelectTool(tool);
227        }
228
229        // Config uses fixed desktop values
230        let _layout_ctx = LayoutContext::from_egui(ui.ctx());
231
232        let sidebar_rect = Rect::from_min_size(
233            ui.available_rect_before_wrap().min,
234            Vec2::new(self.config.width, ui.available_height()),
235        );
236
237        // Background is handled by parent Frame in platform.rs
238
239        // Desktop layout: direct vertical rendering
240        {
241            let mut child_ui = ui.new_child(
242                egui::UiBuilder::new()
243                    .max_rect(sidebar_rect)
244                    .layout(egui::Layout::top_down(egui::Align::Center)),
245            );
246            // Buttons and separators carry their own token gaps.
247            child_ui.spacing_mut().item_spacing = Vec2::ZERO;
248            // The scroll bar floats over the 52 px column, so it never narrows the buttons.
249            child_ui.spacing_mut().scroll.floating = true;
250
251            let content = egui::ScrollArea::vertical()
252                .auto_shrink([false, false])
253                .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded)
254                .show(&mut child_ui, |ui| self.render_toolbar_content(ui))
255                .inner;
256            if content != DrawingToolbarAction::None {
257                action = content;
258            }
259        }
260
261        // === COLOR PICKER POPUP (outside scroll area) ===
262        if self.state.show_color_picker
263            && let Some(new_color) = self.draw_color_picker_popup(ui, sidebar_rect)
264        {
265            self.state.drawing_color = new_color;
266            action = DrawingToolbarAction::ColorChanged(new_color);
267        }
268
269        // === TEMPLATE NAME DIALOG (outside scroll area) ===
270        if self.state.show_template_name_input
271            && let Some(template_action) = TemplatesButton::show_name_dialog(
272                ui,
273                sidebar_rect,
274                &mut self.state.template_name_input,
275            )
276        {
277            self.state.show_template_name_input = false;
278            self.state.template_name_input.clear();
279            if template_action != DrawingToolbarAction::None {
280                action = template_action;
281            }
282        }
283
284        // === TOOL SEARCH ===
285        if self.state.search_open {
286            self.show_search(ui, sidebar_rect);
287        }
288
289        // === SUBMENUS (outside scroll area) ===
290        if let Some(ref category) = self.state.expanded_category.clone()
291            && let Some(tool_action) = self.draw_submenu(ui, category)
292        {
293            action = tool_action;
294        }
295
296        // === MAGNET CARD (outside scroll area) ===
297        self.magnet_hint_open &= self.state.magnet_mode;
298        if self.magnet_hint_open
299            && !self.state.magnet_hint_dismissed
300            && self.state.expanded_category.is_none()
301            && let Some(anchor) = self.magnet_rect
302            && MagnetButton::show_hint(ui, anchor)
303        {
304            self.state.magnet_hint_dismissed = true;
305        }
306
307        // === FAVOURITE TOOLS STRIP ===
308        if let Some(tool) = self.show_floating(ui, sidebar_rect.right_top() + Vec2::splat(8.0)) {
309            action = DrawingToolbarAction::SelectTool(tool);
310        }
311
312        ui.allocate_rect(sidebar_rect, Sense::hover());
313        action
314    }
315
316    /// Render the main toolbar content
317    fn render_toolbar_content(&mut self, ui: &mut Ui) -> DrawingToolbarAction {
318        let mut action = DrawingToolbarAction::None;
319
320        // Add top padding
321        ui.add_space(self.config.padding);
322
323        // "/" opens the tool search, unless a text field has the keyboard.
324        if !self.state.search_open
325            && !ui.ctx().data(|data| {
326                data.get_temp::<crate::widget::ShortcutMap>(Id::NULL.with("chart_shortcuts"))
327                    .is_some()
328            })
329            && !crate::widget::keyboard::shortcuts_suppressed(ui.ctx())
330            && !Popup::is_any_open(ui.ctx())
331            && !ui.ctx().text_edit_focused()
332            && ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Slash))
333        {
334            self.state.search_open = true;
335            self.state.search_query.clear();
336            ui.memory_mut(|m| m.request_focus(Self::search_id()));
337        }
338
339        // === CURSORS BUTTON ===
340        action = self.render_cursors_btn(ui, action);
341
342        // === TOOL CATEGORIES ===
343        action = self.render_tool_categories(ui, action);
344
345        // Separator
346        draw_separator_styled(ui, self.config.width);
347
348        // === BOTTOM TOOLS (using utilities modules) ===
349        self.render_bottom_tools(ui, action)
350    }
351
352    fn search_id() -> Id {
353        Id::unique("drawing_toolbar_search")
354    }
355
356    /// The tool search field, in a flyout beside the top of the bar. It closes with its button
357    /// or Escape.
358    fn show_search(&mut self, ui: &Ui, sidebar_rect: Rect) {
359        let anchor = Rect::from_min_size(
360            sidebar_rect.min,
361            Vec2::new(
362                self.config.width,
363                DESIGN_TOKENS.sizing.toolbar.button_height,
364            ),
365        );
366        let theme = ui.active_theme();
367        let mut open = true;
368        let mut close_clicked = false;
369        let query = &mut self.state.search_query;
370        Popup::new(
371            Id::unique("drawing_toolbar_search_flyout"),
372            ui.ctx().clone(),
373            anchor,
374            ui.layer_id(),
375        )
376        .open_bool(&mut open)
377        .align(egui::RectAlign::RIGHT_START)
378        .gap(theme.spacing.sm)
379        .frame(egui::Frame::menu(ui.style()))
380        .close_behavior(egui::PopupCloseBehavior::IgnoreClicks)
381        .show(|ui| {
382            ui.horizontal(|ui| {
383                egui::TextEdit::singleline(query)
384                    .id(Self::search_id())
385                    .hint_text(l10n::text(ui.ctx(), "Search tools"))
386                    .desired_width(DESIGN_TOKENS.sizing.drawing_toolbar_ext.submenu_width_lg)
387                    .show(ui);
388                close_clicked = IconButton::new(&embedded_icons::CLOSE)
389                    .tooltip("Close search")
390                    .show(ui)
391                    .clicked();
392            });
393        });
394        self.state.search_open = open && !close_clicked;
395        if !self.state.search_open {
396            self.state.search_query.clear();
397        }
398    }
399
400    /// Render cursors button
401    fn render_cursors_btn(
402        &mut self,
403        ui: &mut Ui,
404        action: DrawingToolbarAction,
405    ) -> DrawingToolbarAction {
406        // Determine the current cursor icon based on active mode
407        let (cursor_icon, cursor_tooltip): (&Icon, &str) = if self.state.eraser_mode_enabled {
408            (&embedded_icons::ERASER, "Eraser")
409        } else {
410            (
411                self.state.current_cursor_type.icon(),
412                self.state.current_cursor_type.name(),
413            )
414        };
415
416        // Zoom is a mode of its own, so it takes the highlight from the cursor.
417        let is_selected = self.state.sel_tool.is_none() && !self.state.zoom_mode_active;
418
419        // Simple button without arrow
420        let response = draw_tool_button(ui, cursor_icon, cursor_tooltip, is_selected);
421        self.menu_button(ui, "Cursors", &response);
422
423        action
424    }
425
426    /// A click on `response` opens the flyout `category`, or closes it when it is open; the
427    /// flyout opens beside the button.
428    fn menu_button(&mut self, ui: &Ui, category: &str, response: &egui::Response) {
429        if response.clicked() {
430            self.toggle_category(ui.ctx(), category, response.rect);
431        }
432        if self.state.expanded_category.as_deref() == Some(category) {
433            self.expanded_category_rect = Some(response.rect);
434        }
435    }
436
437    /// Render tool category btns
438    fn render_tool_categories(
439        &mut self,
440        ui: &mut Ui,
441        action: DrawingToolbarAction,
442    ) -> DrawingToolbarAction {
443        let primary_categories = [
444            "Lines",
445            "Fibonacci",
446            "Patterns",
447            "Projection",
448            "Brushes/Shapes",
449            "Text/Annotations",
450            "Icons/Emojis",
451        ];
452
453        for category in primary_categories {
454            let sections = data::get_category_sections(category);
455            if sections.is_empty() {
456                continue;
457            }
458
459            let mut tools: Vec<DrawingToolType> = sections
460                .iter()
461                .flat_map(|(_, tools)| tools.iter())
462                .copied()
463                .collect();
464
465            tools.retain(|tool| self.registry.availability(*tool).visible);
466
467            // Filter by search
468            if !self.state.search_query.is_empty() {
469                let query = self.state.search_query.to_lowercase();
470                tools.retain(|tool| {
471                    tool.as_str().to_lowercase().contains(&query)
472                        || l10n::text(ui.ctx(), tool.as_str())
473                            .to_lowercase()
474                            .contains(&query)
475                });
476            }
477
478            let is_icons = category == "Icons/Emojis";
479            if tools.is_empty() {
480                continue;
481            }
482
483            // Determine display tool
484            let display_tool = if is_icons {
485                None
486            } else if let Some(selected) = self.state.sel_tool {
487                if tools.contains(&selected) {
488                    Some(selected)
489                } else {
490                    self.state
491                        .category_last_tool
492                        .get(category)
493                        .copied()
494                        .filter(|tool| tools.contains(tool))
495                        .or_else(|| tools.first().copied())
496                }
497            } else {
498                self.state
499                    .category_last_tool
500                    .get(category)
501                    .copied()
502                    .filter(|tool| tools.contains(tool))
503                    .or_else(|| tools.first().copied())
504            };
505
506            let tool_icon = display_tool
507                .map(icons::get_icon)
508                .unwrap_or(&embedded_icons::EMOJI_ICON);
509
510            let has_sel = self.state.sel_tool.is_some_and(|t| tools.contains(&t));
511
512            let tool_name = display_tool.map(|t| t.as_str().to_string());
513            let tooltip = tool_name.as_deref().unwrap_or(category);
514
515            // Simple button without arrow
516            let response = draw_tool_button(ui, tool_icon, tooltip, has_sel);
517            self.menu_button(ui, category, &response);
518        }
519
520        action
521    }
522
523    /// Render bottom tools section
524    fn render_bottom_tools(
525        &mut self,
526        ui: &mut Ui,
527        mut action: DrawingToolbarAction,
528    ) -> DrawingToolbarAction {
529        let width = self.config.width;
530
531        // Measure tool
532        let measure_active = self.state.sel_tool == Some(DrawingToolType::Measure);
533        let measure = self.registry.availability(DrawingToolType::Measure);
534        if measure.visible
535            && let Some(a) = ui
536                .add_enabled_ui(measure.enabled, |ui| {
537                    MeasureButton::show(ui, measure_active)
538                })
539                .inner
540        {
541            self.select_tool(DrawingToolType::Measure);
542            action = a;
543        }
544
545        // Zoom In
546        if let Some(a) = ZoomInButton::show(ui, self.state.zoom_mode_active) {
547            self.state.zoom_mode_active = !self.state.zoom_mode_active;
548            action = a;
549        }
550
551        // Zoom Out (only if history)
552        if self.state.has_zoom_history
553            && let Some(a) = ZoomOutButton::show(ui)
554        {
555            action = a;
556        }
557
558        // Separator
559        draw_separator_styled(ui, width);
560
561        // Magnet: a click switches snapping, a secondary click opens the strength menu.
562        let response = MagnetButton::show(ui, self.state.magnet_mode);
563        self.magnet_rect = ui.is_rect_visible(response.rect).then_some(response.rect);
564        if response.clicked() {
565            self.state.magnet_mode = !self.state.magnet_mode;
566            self.magnet_hint_open |= self.state.magnet_mode;
567            action = DrawingToolbarAction::ToggleMagnet;
568        }
569        if response.secondary_clicked() {
570            self.toggle_category(ui.ctx(), "Magnet", response.rect);
571        }
572        if self.state.expanded_category.as_deref() == Some("Magnet") {
573            self.expanded_category_rect = Some(response.rect);
574        }
575
576        // Keep Drawing
577        if let Some(a) = StayInDrawingButton::show(ui, self.state.stay_in_drawing_mode) {
578            self.state.stay_in_drawing_mode = !self.state.stay_in_drawing_mode;
579            action = a;
580        }
581
582        // Lock All Drawings
583        if let Some(a) = LockButton::show(ui, self.state.lock_all) {
584            self.state.lock_all = !self.state.lock_all;
585            action = a;
586        }
587
588        // Hide dropdown
589        let response = HideMenu::show(ui);
590        self.menu_button(ui, "Hide", &response);
591
592        // Favourite tools strip
593        let strip_shown = self.state.floating_visible && !self.state.favorites.is_empty();
594        if FavoritesButton::show(ui, strip_shown) {
595            self.state.floating_visible = !strip_shown;
596        }
597
598        // Separator
599        draw_separator_styled(ui, width);
600
601        // Remove dropdown
602        let response = RemoveMenu::show(ui);
603        self.menu_button(ui, "Remove", &response);
604
605        action
606    }
607
608    /// Open the flyout `category` beside `rect`, or close it when it is the open one.
609    fn toggle_category(&mut self, ctx: &egui::Context, category: &str, rect: Rect) {
610        if self.state.expanded_category.as_deref() == Some(category) {
611            self.close_flyout(ctx);
612        } else {
613            self.state.expanded_category = Some(category.to_string());
614            self.expanded_category_rect = Some(rect);
615            Popup::open_id(ctx, flyout_id(category));
616        }
617    }
618
619    fn close_flyout(&mut self, ctx: &egui::Context) {
620        if let Some(category) = self.state.expanded_category.take() {
621            Popup::close_id(ctx, flyout_id(&category));
622        }
623        self.expanded_category_rect = None;
624    }
625
626    /// Show the flyout of `category`, apply what the user picked in it to the toolbar state and
627    /// return the action for the host.
628    fn draw_submenu(&mut self, ui: &mut Ui, category: &str) -> Option<DrawingToolbarAction> {
629        let anchor = self.expanded_category_rect?;
630        let ctx = ui.ctx().clone();
631        ctx.data_mut(|d| {
632            d.insert_temp(
633                CursorStateData::id(),
634                CursorStateData {
635                    current_cursor: self.state.current_cursor_type,
636                    eraser_mode: self.state.eraser_mode_enabled,
637                    values_tooltip: self.state.values_tooltip_on_long_press,
638                },
639            );
640        });
641
642        let trading = !self.hidden.contains(&Control::Trading);
643        let sel_tool = self.state.sel_tool;
644        let starred = self.state.favorites.len();
645        let state = &mut self.state;
646        let registry = &self.registry;
647        let action = show_flyout(ui, category, anchor, |ui| match category {
648            "Magnet" => MagnetButton::show_submenu(
649                ui,
650                state.magnet_mode,
651                state.magnet_type,
652                state.snap_to_indicators,
653            ),
654            "Hide" => HideMenu::show_submenu(ui, trading, state),
655            "Remove" => RemoveMenu::show_submenu(ui),
656            "Icons/Emojis" => state.glyph_picker.show(ui, registry),
657            _ => {
658                let favorites = &mut state.favorites;
659                let action = if category == "Cursors" {
660                    CursorsCategory.render_submenu(ui, sel_tool, favorites)
661                } else {
662                    render_registered_tool_submenu(
663                        ui,
664                        data::get_category_sections(category),
665                        sel_tool,
666                        favorites,
667                        registry,
668                    )
669                };
670                (action != DrawingToolbarAction::None).then_some(action)
671            }
672        })
673        .flatten();
674        // Starring a tool shows the strip it joins.
675        if self.state.favorites.len() > starred {
676            self.state.floating_visible = true;
677        }
678
679        // Escape and a click outside close the flyout through egui's popup state.
680        if !Popup::is_id_open(&ctx, flyout_id(category)) {
681            self.state.expanded_category = None;
682            self.expanded_category_rect = None;
683        }
684        let action = self.apply_pick(action?);
685        // A tool picked while keeping the drawing mode on leaves the flyout open for the next one.
686        let keeps_open = matches!(action, Some(DrawingToolbarAction::SelectTool(_)))
687            && self.state.stay_in_drawing_mode;
688        if !keeps_open {
689            self.close_flyout(&ctx);
690        }
691        action
692    }
693
694    /// Mirror a pick from a flyout in the toolbar state. Returns the action for the host, or
695    /// `None` when the pick has no effect to report.
696    fn apply_pick(&mut self, action: DrawingToolbarAction) -> Option<DrawingToolbarAction> {
697        use DrawingToolbarAction as A;
698        let tool = match &action {
699            A::SelectTool(tool) => Some(*tool),
700            A::InsertEmoji { .. } => Some(DrawingToolType::FontIcon),
701            A::InsertGlyph { icon_name, .. } => Some(if icon_name.starts_with("sticker:") {
702                DrawingToolType::Sticker
703            } else {
704                DrawingToolType::FontIcon
705            }),
706            _ => None,
707        };
708        if tool.is_some_and(|tool| !self.registry.availability(tool).enabled) {
709            return None;
710        }
711        match &action {
712            A::SelectTool(tool) => self.select_tool(*tool),
713            A::SetCursorType(cursor) => {
714                self.clear_selection();
715                self.state.current_cursor_type = *cursor;
716                self.state.eraser_mode_enabled = false;
717            }
718            A::ToggleEraserMode => {
719                self.clear_selection();
720                self.state.eraser_mode_enabled = !self.state.eraser_mode_enabled;
721            }
722            A::ToggleValuesTooltip => {
723                self.state.values_tooltip_on_long_press = !self.state.values_tooltip_on_long_press;
724            }
725            A::SetSnapToIndicators(enabled) => self.state.snap_to_indicators = *enabled,
726            A::SetMagnetType(kind) => {
727                self.state.magnet_type = *kind;
728                self.state.magnet_mode = true;
729                self.magnet_hint_open = true;
730            }
731            // "Off" while the magnet is already off changes nothing, and the host's toggle would
732            // switch it on.
733            A::ToggleMagnet if !self.state.magnet_mode => return None,
734            A::ToggleMagnet => self.state.magnet_mode = false,
735            A::HideAllDrawings => self.state.hide_drawings = !self.state.hide_drawings,
736            A::HideAllIndicators => self.state.hide_indicators = !self.state.hide_indicators,
737            A::HideAllPoss => self.state.hide_positions = !self.state.hide_positions,
738            A::HideAllOrders => self.state.hide_orders = !self.state.hide_orders,
739            A::HideAll => {
740                let state = &mut self.state;
741                state.hide_drawings = true;
742                state.hide_indicators = true;
743                state.hide_positions = true;
744                state.hide_orders = true;
745            }
746            A::InsertEmoji { .. } => self.select_tool(DrawingToolType::FontIcon),
747            A::InsertGlyph { icon_name, .. } => {
748                self.select_tool(if icon_name.starts_with("sticker:") {
749                    DrawingToolType::Sticker
750                } else {
751                    DrawingToolType::FontIcon
752                })
753            }
754            A::OpenTemplateMenu => {
755                // "Save as template" closes the menu and opens the name dialog.
756                self.state.show_template_name_input = true;
757                self.state.template_name_input.clear();
758                return None;
759            }
760            _ => {}
761        }
762        Some(action)
763    }
764}
765
766#[cfg(test)]
767mod tests {
768    use super::*;
769    use egui::{Context, Event, Key, Modifiers, Pos2, RawInput};
770
771    fn label(shapes: &[egui::Shape], value: &str) -> Option<Rect> {
772        shapes.iter().find_map(|s| match s {
773            egui::Shape::Text(t) if t.galley.job.text == value => Some(t.visual_bounding_rect()),
774            _ => None,
775        })
776    }
777
778    /// The floating strip lists the favorite tools as they change, stays where it was first put
779    /// and where the host moves it, and hides until shown again.
780    #[test]
781    fn floating_strip_follows_favorites_and_keeps_position_and_visibility() {
782        let ctx = Context::default();
783        let id = Id::NULL.with("floating_drawing_toolbar");
784        let mut toolbar = DrawingToolbar::new();
785        toolbar.state.favorites = vec![
786            DrawingToolType::TrendLine,
787            DrawingToolType::HorizontalLine,
788            DrawingToolType::FibonacciRetracement,
789        ];
790        let frame = |toolbar: &mut DrawingToolbar, at: Pos2| {
791            ctx.run_ui(RawInput::default(), |ui| {
792                toolbar.show_floating(ui, at);
793            })
794            .drop_without_applying_deltas();
795            ctx.memory(|memory| memory.area_rect(id))
796        };
797        let three = frame(&mut toolbar, Pos2::new(20.0, 30.0)).unwrap();
798        assert_eq!(three.min, Pos2::new(20.0, 30.0));
799        toolbar.state.favorites.pop();
800        let two = frame(&mut toolbar, Pos2::new(300.0, 300.0)).unwrap();
801        assert!(
802            two.width() < three.width() - 20.0,
803            "{two:?} after unstarring from {three:?}"
804        );
805        assert_eq!(two.min, three.min, "the strip stays where it was put");
806        assert_eq!(toolbar.state.floating_pos, Some(three.min));
807        toolbar.state.floating_visible = false;
808        let before = ctx.memory(|memory| memory.area_rect(id));
809        frame(&mut toolbar, Pos2::ZERO);
810        assert_eq!(ctx.memory(|memory| memory.area_rect(id)), before);
811    }
812
813    /// The flyout searches all families, emits the picked glyph and shows session recents first.
814    #[test]
815    fn glyph_flyout_searches_places_and_shows_recents() {
816        fn frame(
817            toolbar: &mut DrawingToolbar,
818            ctx: &egui::Context,
819            events: Vec<egui::Event>,
820        ) -> (Option<DrawingToolbarAction>, Vec<egui::Shape>) {
821            let mut action = None;
822            let output = ctx.run_ui(
823                egui::RawInput {
824                    events,
825                    ..Default::default()
826                },
827                |ui| {
828                    action = toolbar.draw_submenu(ui, "Icons/Emojis");
829                },
830            );
831            let shapes = output.shapes.iter().map(|s| s.shape.clone()).collect();
832            output.drop_without_applying_deltas();
833            (action, shapes)
834        }
835        fn click(
836            toolbar: &mut DrawingToolbar,
837            ctx: &egui::Context,
838            at: Pos2,
839        ) -> Option<DrawingToolbarAction> {
840            frame(toolbar, ctx, vec![egui::Event::PointerMoved(at)]);
841            frame(
842                toolbar,
843                ctx,
844                [true, false]
845                    .map(|pressed| egui::Event::PointerButton {
846                        pos: at,
847                        button: egui::PointerButton::Primary,
848                        pressed,
849                        modifiers: Modifiers::NONE,
850                    })
851                    .to_vec(),
852            )
853            .0
854        }
855        for dark in [false, true] {
856            for (width, height) in [(240.0, 320.0), (320.0, 400.0), (1440.0, 900.0)] {
857                let ctx = egui::Context::default();
858                ctx.set_visuals(if dark {
859                    egui::Visuals::dark()
860                } else {
861                    egui::Visuals::light()
862                });
863                l10n::set_translator(&ctx, |english| (english == "Star").then(|| "Etoile".into()));
864                let screen = Rect::from_min_size(Pos2::ZERO, Vec2::new(width, height));
865                ctx.run_ui(
866                    RawInput {
867                        screen_rect: Some(screen),
868                        ..Default::default()
869                    },
870                    |_| {},
871                )
872                .drop_without_applying_deltas();
873                let mut toolbar = DrawingToolbar::new();
874                toolbar.state.glyph_picker.size = 48.0;
875                let anchor = Rect::from_min_size(Pos2::new(10., 100.), Vec2::splat(40.));
876                let mut previous = "Search icons, emoji and stickers";
877                for (query, glyph) in [
878                    ("star", "icon:star"),
879                    ("etoile", "icon:star"),
880                    ("grinning", "😀"),
881                    ("idea", "sticker:idea"),
882                    ("", "sticker:idea"),
883                ] {
884                    toolbar.toggle_category(&ctx, "Icons/Emojis", anchor);
885                    frame(&mut toolbar, &ctx, vec![]);
886                    let (_, shapes) = frame(&mut toolbar, &ctx, vec![]);
887                    let input =
888                        label(&shapes, previous).expect("the flyout must have a search field");
889                    assert!(
890                        screen.contains_rect(input),
891                        "search field outside {screen:?}: {input:?}"
892                    );
893                    click(&mut toolbar, &ctx, input.center());
894                    let modifiers = Modifiers {
895                        ctrl: true,
896                        command: true,
897                        ..Modifiers::NONE
898                    };
899                    frame(
900                        &mut toolbar,
901                        &ctx,
902                        [true, false]
903                            .map(|pressed| egui::Event::Key {
904                                key: Key::A,
905                                physical_key: None,
906                                pressed,
907                                repeat: false,
908                                modifiers,
909                            })
910                            .to_vec(),
911                    );
912                    frame(&mut toolbar, &ctx, vec![egui::Event::Text(query.into())]);
913                    if query.is_empty() {
914                        frame(
915                            &mut toolbar,
916                            &ctx,
917                            [true, false]
918                                .map(|pressed| egui::Event::Key {
919                                    key: Key::Backspace,
920                                    physical_key: None,
921                                    pressed,
922                                    repeat: false,
923                                    modifiers: Modifiers::NONE,
924                                })
925                                .to_vec(),
926                        );
927                    }
928                    let (_, shapes) = frame(&mut toolbar, &ctx, vec![]);
929                    if query == "star" {
930                        assert!(label(&shapes, "Icons: shapes").is_some());
931                        assert!(label(&shapes, "Emoji: symbols").is_some());
932                        assert!(label(&shapes, "Emoji: faces").is_none());
933                    }
934                    if query.is_empty() {
935                        assert!(
936                            label(&shapes, "Recent").unwrap().top()
937                                < label(&shapes, "Icons: signals").unwrap().top()
938                        );
939                    }
940                    let cell = shapes
941                        .iter()
942                        .find_map(|s| match s {
943                            egui::Shape::Rect(r) if r.rect.size() == Vec2::splat(40.0) => {
944                                Some(r.rect.center())
945                            }
946                            _ => None,
947                        })
948                        .unwrap();
949                    assert!(
950                        screen.contains(cell),
951                        "first glyph outside {screen:?}: {cell:?}"
952                    );
953                    let action = click(&mut toolbar, &ctx, cell);
954                    assert_eq!(
955                        action,
956                        Some(DrawingToolbarAction::InsertGlyph {
957                            icon_name: glyph,
958                            size: 48.0
959                        })
960                    );
961                    previous = query;
962                }
963            }
964        }
965    }
966
967    /// Host snapshots restore modes, clear conflicting zoom and retain menus and tool history.
968    #[test]
969    fn domain_snapshots_restore_magnet_cursor_and_eraser_modes() {
970        for (magnet, cursor, eraser, tool, zoom) in [
971            (
972                MagnetType::Weak,
973                CursorType::Arrow,
974                false,
975                Some(DrawingToolType::HorizontalLine),
976                false,
977            ),
978            (MagnetType::Strong, CursorType::Dot, false, None, false),
979            (MagnetType::Strong, CursorType::Arrow, true, None, false),
980            (MagnetType::Strong, CursorType::Arrow, false, None, true),
981        ] {
982            let mut source = DrawingToolbar::new();
983            source.state.magnet_mode = true;
984            source.state.magnet_type = magnet;
985            source.state.snap_to_indicators = magnet != MagnetType::Weak;
986            source.state.current_cursor_type = cursor;
987            source.state.eraser_mode_enabled = eraser;
988            source.state.sel_tool = tool;
989            let mut target = DrawingToolbar::new();
990            target.select_tool(DrawingToolType::TrendLine);
991            target.clear_selection();
992            target.state.zoom_mode_active = true;
993            target.state.snap_to_indicators = !source.state.snap_to_indicators;
994            target.state.expanded_category = Some("Magnet".into());
995            target.sync_from_domain_state(&source.get_domain_state());
996            assert_eq!(target.state.magnet_type, magnet);
997            assert_eq!(
998                target.state.snap_to_indicators,
999                source.state.snap_to_indicators
1000            );
1001            assert_eq!(target.state.current_cursor_type, cursor);
1002            assert_eq!(target.state.eraser_mode_enabled, eraser);
1003            assert!(target.state.magnet_mode);
1004            assert_eq!(target.state.sel_tool, tool);
1005            assert_eq!(target.state.zoom_mode_active, zoom);
1006            assert_eq!(target.state.expanded_category.as_deref(), Some("Magnet"));
1007            assert_eq!(
1008                target.state.recent.front(),
1009                Some(&DrawingToolType::TrendLine)
1010            );
1011        }
1012    }
1013
1014    /// Cursor, eraser and drawing picks leave the previous mode before a host synchronises.
1015    #[test]
1016    fn flyout_picks_leave_the_previous_drawing_mode() {
1017        use DrawingToolType::TrendLine;
1018        use DrawingToolbarAction::{SelectTool, SetCursorType, ToggleEraserMode};
1019
1020        let mut toolbar = DrawingToolbar::new();
1021        toolbar.select_tool(TrendLine);
1022        for (action, tool, eraser) in [
1023            (SetCursorType(CursorType::Dot), None, false),
1024            (SelectTool(TrendLine), Some(TrendLine), false),
1025            (ToggleEraserMode, None, true),
1026            (SelectTool(TrendLine), Some(TrendLine), false),
1027            (ToggleEraserMode, None, true),
1028            (ToggleEraserMode, None, false),
1029        ] {
1030            assert_eq!(toolbar.apply_pick(action.clone()), Some(action));
1031            assert_eq!(toolbar.state.sel_tool, tool);
1032            assert_eq!(toolbar.state.eraser_mode_enabled, eraser);
1033            assert_eq!(toolbar.state.current_cursor_type, CursorType::Dot);
1034        }
1035        for enabled in [false, true] {
1036            let mode = toolbar.state.magnet_type;
1037            let on = toolbar.state.magnet_mode;
1038            let action = DrawingToolbarAction::SetSnapToIndicators(enabled);
1039            assert_eq!(toolbar.apply_pick(action.clone()), Some(action));
1040            assert_eq!(toolbar.state.snap_to_indicators, enabled);
1041            assert_eq!(toolbar.state.magnet_type, mode);
1042            assert_eq!(toolbar.state.magnet_mode, on);
1043        }
1044        toolbar.registry.set_availability(
1045            TrendLine,
1046            crate::drawings::ToolAvailability {
1047                visible: false,
1048                enabled: false,
1049            },
1050        );
1051        assert_eq!(toolbar.apply_pick(SelectTool(TrendLine)), None);
1052        let ctx = Context::default();
1053        let output = ctx.run_ui(RawInput::default(), |ui| {
1054            render_registered_tool_submenu(
1055                ui,
1056                data::get_category_sections("Lines"),
1057                None,
1058                &mut vec![],
1059                &toolbar.registry,
1060            );
1061        });
1062        let labels: Vec<_> = output
1063            .shapes
1064            .iter()
1065            .filter_map(|shape| match &shape.shape {
1066                egui::Shape::Text(text) => Some(text.galley.job.text.as_str()),
1067                _ => None,
1068            })
1069            .collect();
1070        assert!(!labels.contains(&TrendLine.as_str()));
1071        assert!(labels.contains(&DrawingToolType::HorizontalLine.as_str()));
1072        output.drop_without_applying_deltas();
1073    }
1074
1075    /// Primary clicks toggle snapping; secondary clicks expose each strength once.
1076    #[test]
1077    fn magnet_click_switches_it_and_secondary_click_opens_the_strengths() {
1078        use egui::{PointerButton, Pos2, Shape, vec2};
1079        let ctx = Context::default();
1080        let mut toolbar = DrawingToolbar::new();
1081        let frame = |toolbar: &mut DrawingToolbar, events| {
1082            let mut action = DrawingToolbarAction::None;
1083            let mut origin = Pos2::ZERO;
1084            ctx.run_ui(
1085                RawInput {
1086                    screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(52.0, 1200.0))),
1087                    events,
1088                    ..Default::default()
1089                },
1090                |ui| {
1091                    ui.spacing_mut().item_spacing = Vec2::ZERO;
1092                    origin = ui.cursor().min;
1093                    action = toolbar.render_toolbar_content(ui);
1094                },
1095            )
1096            .drop_without_applying_deltas();
1097            (action, origin + vec2(26.0, 437.0))
1098        };
1099        let (_, pos) = frame(&mut toolbar, vec![]);
1100        frame(&mut toolbar, vec![Event::PointerMoved(pos)]);
1101        for (button, expected_action, expected_on) in [
1102            (
1103                PointerButton::Primary,
1104                DrawingToolbarAction::ToggleMagnet,
1105                true,
1106            ),
1107            (
1108                PointerButton::Primary,
1109                DrawingToolbarAction::ToggleMagnet,
1110                false,
1111            ),
1112            (PointerButton::Secondary, DrawingToolbarAction::None, false),
1113        ] {
1114            for pressed in [true, false] {
1115                let (action, _) = frame(
1116                    &mut toolbar,
1117                    vec![Event::PointerButton {
1118                        pos,
1119                        button,
1120                        pressed,
1121                        modifiers: Modifiers::NONE,
1122                    }],
1123                );
1124                if !pressed {
1125                    assert_eq!(action, expected_action);
1126                    assert_eq!(toolbar.state.magnet_mode, expected_on);
1127                }
1128            }
1129        }
1130        assert_eq!(toolbar.state.expanded_category.as_deref(), Some("Magnet"));
1131        for pass in 0..2 {
1132            let output = ctx.run_ui(
1133                RawInput {
1134                    screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(640.0, 1200.0))),
1135                    ..Default::default()
1136                },
1137                |ui| {
1138                    toolbar.draw_submenu(ui, "Magnet");
1139                },
1140            );
1141            if pass == 1 {
1142                let labels: Vec<_> = output
1143                    .shapes
1144                    .iter()
1145                    .filter_map(|s| match &s.shape {
1146                        Shape::Text(text) => Some(text.galley.text()),
1147                        _ => None,
1148                    })
1149                    .collect();
1150                for label in ["Weak magnet", "Strong magnet"] {
1151                    assert_eq!(labels.iter().filter(|&&text| text == label).count(), 1);
1152                }
1153                assert!(!labels.contains(&"OHLC magnet"));
1154            }
1155            output.drop_without_applying_deltas();
1156        }
1157    }
1158
1159    /// The card follows a switch-on from the toolbar, beside the magnet, never a restored magnet,
1160    /// and stays away once its button dismissed it.
1161    #[test]
1162    fn magnet_card_shows_after_the_first_switch_on_until_dismissed() {
1163        use egui::{PointerButton, Shape, vec2};
1164        const EXPLANATION: &str =
1165            "Drawing points snap to the open, high, low or close of the nearest bar.";
1166        let ctx = Context::default();
1167        let frame = |toolbar: &mut DrawingToolbar, events| {
1168            let mut origin = Pos2::ZERO;
1169            let output = ctx.run_ui(
1170                RawInput {
1171                    screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(640.0, 1200.0))),
1172                    events,
1173                    ..Default::default()
1174                },
1175                |ui| {
1176                    ui.spacing_mut().item_spacing = Vec2::ZERO;
1177                    origin = ui.cursor().min;
1178                    toolbar.show_with_action(ui);
1179                },
1180            );
1181            let shapes: Vec<_> = output.shapes.iter().map(|s| s.shape.clone()).collect();
1182            output.drop_without_applying_deltas();
1183            (shapes, origin)
1184        };
1185        // A newly shown area may stay invisible in its first pass, so one more pass follows.
1186        let click = |toolbar: &mut DrawingToolbar, pos| {
1187            frame(toolbar, vec![Event::PointerMoved(pos)]);
1188            for pressed in [true, false] {
1189                frame(
1190                    toolbar,
1191                    vec![Event::PointerButton {
1192                        pos,
1193                        button: PointerButton::Primary,
1194                        pressed,
1195                        modifiers: Modifiers::NONE,
1196                    }],
1197                );
1198            }
1199            frame(toolbar, vec![]).0
1200        };
1201
1202        // A magnet the host restored switched on brings no card.
1203        let mut toolbar = DrawingToolbar::new();
1204        toolbar.sync_from_domain_state(&DrawingToolbarDomainState {
1205            magnet_mode: true,
1206            ..toolbar.get_domain_state()
1207        });
1208        frame(&mut toolbar, vec![]);
1209        let (shapes, origin) = frame(&mut toolbar, vec![]);
1210        assert!(label(&shapes, "Got it").is_none());
1211
1212        // Off, then on: the card sits right of the 52 x 38 button at (0, 418), past a gap of 4
1213        // and its margin of 20, and is centred on the button.
1214        let magnet = origin + vec2(26.0, 437.0);
1215        let shapes = click(&mut toolbar, magnet);
1216        assert!(!toolbar.state.magnet_mode);
1217        assert!(label(&shapes, "Got it").is_none());
1218        let shapes = click(&mut toolbar, magnet);
1219        assert!(toolbar.state.magnet_mode);
1220        let text_pos = shapes.iter().find_map(|s| match s {
1221            Shape::Text(t) if t.galley.job.text.starts_with(EXPLANATION) => Some(t.pos),
1222            _ => None,
1223        });
1224        let text_pos = text_pos.unwrap();
1225        assert_eq!(text_pos.x, origin.x + 76.0);
1226        let got_it = label(&shapes, "Got it").unwrap();
1227        // The button is 34 high.
1228        let card_centre = (text_pos.y - 20.0 + got_it.center().y + 17.0 + 20.0) / 2.0;
1229        assert!((card_centre - magnet.y).abs() <= 1.0);
1230
1231        // The button dismisses it for good, also for the next switch-on.
1232        let shapes = click(&mut toolbar, got_it.center());
1233        assert!(toolbar.state.magnet_hint_dismissed);
1234        assert!(label(&shapes, "Got it").is_none());
1235        click(&mut toolbar, magnet);
1236        let shapes = click(&mut toolbar, magnet);
1237        assert!(toolbar.state.magnet_mode);
1238        assert!(label(&shapes, "Got it").is_none());
1239
1240        // A strength picked from the menu switches the magnet on as well.
1241        let mut toolbar = DrawingToolbar::new();
1242        toolbar.apply_pick(DrawingToolbarAction::SetMagnetType(MagnetType::Weak));
1243        frame(&mut toolbar, vec![]);
1244        let (shapes, _) = frame(&mut toolbar, vec![]);
1245        assert!(label(&shapes, "Got it").is_some());
1246
1247        // Switched off, then restored on by the host, it brings no card.
1248        toolbar.apply_pick(DrawingToolbarAction::ToggleMagnet);
1249        frame(&mut toolbar, vec![]);
1250        toolbar.sync_from_domain_state(&DrawingToolbarDomainState {
1251            magnet_mode: true,
1252            ..toolbar.get_domain_state()
1253        });
1254        frame(&mut toolbar, vec![]);
1255        let (shapes, _) = frame(&mut toolbar, vec![]);
1256        assert!(label(&shapes, "Got it").is_none());
1257    }
1258
1259    /// The bar keeps its grid and utility order, with distinct selection and magnet fills.
1260    #[test]
1261    fn drawing_bar_icons_keep_their_grid_pitch_and_state_colours() {
1262        use egui::emath::GuiRounding;
1263        use egui::{Color32, Pos2, Shape, vec2};
1264
1265        let mut custom = crate::theme::Theme::dark();
1266        custom.ui.text = Color32::GREEN;
1267        custom.ui.accent_text = Color32::YELLOW;
1268        for (theme, text) in [
1269            (crate::theme::Theme::dark(), Color32::from_gray(219)),
1270            (crate::theme::Theme::light(), Color32::BLACK),
1271            (custom, Color32::GREEN),
1272        ] {
1273            for (selected, magnet) in [
1274                (None, false),
1275                (None, true),
1276                (Some(DrawingToolType::TrendLine), false),
1277            ] {
1278                let ctx = Context::default();
1279                egui_extras::install_image_loaders(&ctx);
1280                crate::theme::apply_to_egui(&ctx, &theme);
1281                let mut toolbar = DrawingToolbar::new();
1282                toolbar.state.sel_tool = selected;
1283                toolbar.state.magnet_mode = magnet;
1284                let mut origin = Pos2::ZERO;
1285                let mut shapes = Vec::new();
1286                for _ in 0..2 {
1287                    let mut output = ctx.run_ui(
1288                        RawInput {
1289                            events: vec![Event::PointerMoved(egui::pos2(26.0, 61.0))],
1290                            screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(52.0, 1200.0))),
1291                            ..Default::default()
1292                        },
1293                        |ui| {
1294                            ui.spacing_mut().item_spacing = Vec2::ZERO;
1295                            origin = ui.cursor().min;
1296                            toolbar.render_toolbar_content(ui);
1297                        },
1298                    );
1299                    shapes = std::mem::take(&mut output.shapes);
1300                    output.drop_without_applying_deltas();
1301                }
1302                let icons: Vec<_> = shapes
1303                    .iter()
1304                    .filter_map(|s| match &s.shape {
1305                        Shape::Rect(r) if r.brush.is_some() => Some((r.rect, r.fill)),
1306                        _ => None,
1307                    })
1308                    .collect();
1309                assert_eq!(icons.len(), 16);
1310                for (i, y) in [
1311                    23.0, 61.0, 99.0, 137.0, 175.0, 213.0, 251.0, 289.0, 344.0, 382.0, 437.0,
1312                    475.0, 513.0, 551.0, 589.0, 644.0,
1313                ]
1314                .into_iter()
1315                .enumerate()
1316                {
1317                    let colour = if (i == 0 && selected.is_none()) || (i == 1 && selected.is_some())
1318                    {
1319                        text
1320                    } else if i == 10 && magnet {
1321                        if theme.dark {
1322                            theme.ui.panel_bg
1323                        } else {
1324                            Color32::WHITE
1325                        }
1326                    } else {
1327                        text
1328                    };
1329                    assert_eq!(
1330                        icons[i],
1331                        (
1332                            Rect::from_center_size(
1333                                origin + vec2(26.0, y),
1334                                match i {
1335                                    1 => vec2(29.0, 29.0),
1336                                    2 => vec2(27.0, 27.0),
1337                                    0 | 3 | 4 | 8..=11 => vec2(28.0, 28.0),
1338                                    6 => vec2(24.0, 24.0),
1339                                    5 => vec2(31.0, 31.0),
1340                                    7 => vec2(25.0, 25.0),
1341                                    12..=15 => vec2(26.0, 26.0),
1342                                    _ => unreachable!(),
1343                                }
1344                            )
1345                            .round_to_pixels(1.0),
1346                            colour
1347                        ),
1348                        "icon {i}, selected {selected:?}"
1349                    );
1350                }
1351                let selected_y = if selected.is_none() { 5.5 } else { 43.5 };
1352                let selected_fill = if theme.dark {
1353                    theme.ui.btn_bg_active
1354                } else {
1355                    Color32::from_gray(235)
1356                };
1357                let hovered_fill = if selected.is_none() {
1358                    theme.ui.btn_bg_hover
1359                } else {
1360                    selected_fill
1361                };
1362                assert!(shapes.iter().any(|s| matches!(
1363                    &s.shape,
1364                    Shape::Rect(r) if r.fill == hovered_fill
1365                        && r.rect == Rect::from_min_size(origin + vec2(8.5, 43.5), vec2(35.0, 35.0))
1366                )));
1367                assert!(shapes.iter().any(|s| matches!(
1368                    &s.shape,
1369                    Shape::Rect(r) if r.fill == selected_fill
1370                        && r.rect == Rect::from_min_size(origin + vec2(8.5, selected_y), vec2(35.0, 35.0))
1371                )));
1372                if magnet {
1373                    let fill = if theme.dark {
1374                        theme.ui.text
1375                    } else {
1376                        Color32::from_gray(26)
1377                    };
1378                    assert!(shapes.iter().any(|s| matches!(
1379                        &s.shape,
1380                        Shape::Rect(r) if r.fill == fill
1381                            && r.rect == Rect::from_center_size(origin + vec2(26.0, 437.0), vec2(35.0, 35.0))
1382                    )));
1383                }
1384                let separator = if theme.dark {
1385                    Color32::from_gray(74)
1386                } else {
1387                    Color32::from_gray(235)
1388                };
1389                for y in [316.0, 409.0, 616.0] {
1390                    assert!(shapes.iter().any(|s| matches!(
1391                        &s.shape,
1392                        Shape::Rect(r) if r.fill == separator
1393                            && r.rect == Rect::from_min_size(origin + vec2(9.0, y), vec2(33.0, 1.0))
1394                    )));
1395                }
1396            }
1397        }
1398    }
1399
1400    /// Alt+T selects the trend line tool, unless a text field has the keyboard, where it must not
1401    /// switch tools while the user types.
1402    #[test]
1403    fn a_tool_shortcut_selects_the_tool_unless_a_text_field_has_focus() {
1404        let press_alt_t = |text_focused: bool, modifiers| {
1405            let alt_t = Event::Key {
1406                key: Key::T,
1407                physical_key: None,
1408                pressed: true,
1409                repeat: false,
1410                modifiers,
1411            };
1412            let ctx = Context::default();
1413            let mut toolbar = DrawingToolbar::new();
1414            let mut text = String::new();
1415            let mut frame = |events: Vec<Event>| {
1416                let mut action = DrawingToolbarAction::None;
1417                ctx.run_ui(
1418                    RawInput {
1419                        events,
1420                        ..Default::default()
1421                    },
1422                    |ui| {
1423                        if text_focused {
1424                            ui.text_edit_singleline(&mut text).request_focus();
1425                        }
1426                        action = toolbar.show_with_action(ui);
1427                    },
1428                )
1429                .drop_without_applying_deltas();
1430                action
1431            };
1432            frame(vec![]);
1433            frame(vec![alt_t.clone()])
1434        };
1435        assert_eq!(
1436            press_alt_t(false, Modifiers::ALT),
1437            DrawingToolbarAction::SelectTool(DrawingToolType::TrendLine)
1438        );
1439        assert_eq!(
1440            press_alt_t(true, Modifiers::ALT),
1441            DrawingToolbarAction::None
1442        );
1443        assert_eq!(
1444            press_alt_t(false, Modifiers::ALT.plus(Modifiers::SHIFT)),
1445            DrawingToolbarAction::None
1446        );
1447        for enabled in [false, true] {
1448            for key in [Key::T, Key::Slash] {
1449                let ctx = Context::default();
1450                let mut chart = crate::widget::test_frames::chart(&[10.0], &[20.0]);
1451                chart.chart_options.keyboard.enabled = enabled;
1452                if enabled {
1453                    chart.shortcuts_mut().bindings.clear();
1454                }
1455                let mut toolbar = DrawingToolbar::new();
1456                ctx.run_ui(
1457                    RawInput {
1458                        events: vec![Event::Key {
1459                            key,
1460                            physical_key: None,
1461                            pressed: true,
1462                            repeat: false,
1463                            modifiers: if key == Key::T {
1464                                Modifiers::ALT
1465                            } else {
1466                                Modifiers::NONE
1467                            },
1468                        }],
1469                        ..Default::default()
1470                    },
1471                    |ui| {
1472                        chart.dispatch_shortcuts(ui.ctx());
1473                        assert_eq!(toolbar.show_with_action(ui), DrawingToolbarAction::None);
1474                        assert!(!toolbar.state.search_open);
1475                        assert!(ui.input(|input| input.key_pressed(key)));
1476                    },
1477                )
1478                .drop_without_applying_deltas();
1479            }
1480        }
1481    }
1482}