Skip to main content

egui_charts/ui/top_toolbar/
buttons.rs

1//! Icon, text and dropdown buttons sized for the top toolbar.
2//!
3//! Use these components when extending the top toolbar with host controls.
4
5use crate::localization as l10n;
6use std::sync::Arc;
7
8use crate::icons::Icon;
9use crate::styles::{icons as icon_sizes, stroke};
10use egui::{Color32, Galley, Pos2, Rect, Response, Role, Sense, Ui, Vec2, WidgetInfo};
11
12use super::TopToolbarConfig;
13use crate::theme::{Theme, ThemeExt};
14use crate::tokens::DESIGN_TOKENS;
15use crate::ui::dialogs::keyboard_shortcuts::shortcut_key_labels;
16use crate::ui_kit::ext::UiExt;
17
18/// Menu glyph with three 18 px bars spanning 14 px on the toolbar's 24 px icon grid.
19pub const MENU_ICON: Icon = Icon::new("top-toolbar-menu.svg", include_bytes!("menu.svg"));
20
21/// Small icon-only button (config-driven size)
22///
23/// Used for: undo, redo, settings, theme, fullscreen, screenshot, save, etc.
24///
25/// Note: This is the top-toolbar-specific variant that uses [`TopToolbarConfig`]
26/// for sizing. For a generic icon button, see [`crate::ui_kit::buttons::IconButton`].
27pub struct ToolbarIconButton<'a> {
28    /// Glyph displayed by the component.
29    pub icon: &'a Icon,
30    /// Help text displayed on hover.
31    pub tooltip: &'static str,
32    /// Display and layout options for this component.
33    pub config: TopToolbarConfig,
34}
35
36impl<'a> ToolbarIconButton<'a> {
37    /// Create an icon button with hover text and toolbar sizing.
38    pub fn new(icon: &'a Icon, tooltip: &'static str, config: &TopToolbarConfig) -> Self {
39        Self {
40            icon,
41            tooltip,
42            config: config.clone(),
43        }
44    }
45
46    /// Show the component and return its response.
47    pub fn show(self, ui: &mut Ui) -> Response {
48        // Match glyph ink bounds while preserving the host's configured icon scale.
49        let icon_size = match self.icon.name() {
50            "ui/settings.svg" => Vec2::new(24.0, 22.0),
51            "ui/camera.svg" | "ui/compare-add.svg" => Vec2::splat(23.0),
52            _ => Vec2::splat(24.0),
53        } * (self.config.small_icon_size / 24.0);
54        let btn_size = self.config.btn_size;
55        let (rect, response) = ui.allocate_exact_size(Vec2::splat(btn_size), Sense::click());
56        crate::styles::focus::draw_focus_ring(ui, &response, self.config.rounding);
57
58        // Background with theme-aware colors
59        let bg_color = if response.is_pointer_button_down_on() {
60            ui.visuals().widgets.active.bg_fill
61        } else if response.hovered() {
62            ui.visuals().widgets.hovered.bg_fill
63        } else {
64            Color32::TRANSPARENT
65        };
66
67        ui.painter()
68            .rect_filled(rect, self.config.rounding, bg_color);
69
70        // Icon with hover/pressed color change
71        let is_hovered = response.hovered();
72        let is_active = response.is_pointer_button_down_on();
73
74        // Render icon at exact position (with visibility check)
75        let icon_pos = rect.center() - icon_size / 2.0;
76        let icon_rect = Rect::from_min_size(icon_pos, icon_size);
77        if ui.is_rect_visible(icon_rect) {
78            let icon_color = if !ui.is_enabled() {
79                ui.active_theme().ui.text_disabled
80            } else if is_active || is_hovered {
81                ui.visuals().widgets.hovered.fg_stroke.color
82            } else {
83                ui.visuals().text_color()
84            };
85            self.icon
86                .as_image_tinted(icon_size, icon_color)
87                .paint_at(ui, icon_rect);
88        }
89
90        let translated_label = l10n::text(ui.ctx(), self.tooltip);
91        response
92            .widget_info(|| WidgetInfo::labeled(Role::Button, ui.is_enabled(), &translated_label));
93        toolbar_tooltip(response, self.tooltip, None)
94    }
95}
96
97/// Icon button with text label
98///
99/// Used for: Indicators, Alert, Replay btns.
100pub struct IconTextButton<'a> {
101    /// Glyph displayed by the component.
102    pub icon: &'a Icon,
103    /// Text displayed by the component.
104    pub label: &'static str,
105    /// Display and layout options for this component.
106    pub config: TopToolbarConfig,
107}
108
109impl<'a> IconTextButton<'a> {
110    /// Create an icon and label button with toolbar sizing.
111    pub fn new(icon: &'a Icon, label: &'static str, config: &TopToolbarConfig) -> Self {
112        Self {
113            icon,
114            label,
115            config: config.clone(),
116        }
117    }
118
119    pub(crate) fn width(&self, ui: &Ui) -> f32 {
120        let galley = ui.painter().layout_no_wrap(
121            l10n::text(ui.ctx(), self.label),
122            egui::FontId::proportional(ui.active_theme().text.title),
123            Color32::PLACEHOLDER,
124        );
125        self.config.small_icon_size + self.config.padding * 3.0 + galley.size().x
126    }
127
128    /// Show the component and return its response.
129    pub fn show(self, ui: &mut Ui) -> Response {
130        let icon_size = self.config.small_icon_size;
131        let padding = self.config.padding;
132        // Measure actual text width instead of approximation
133        let total_width = self.width(ui);
134        let height = self.config.btn_size;
135
136        let (rect, response) =
137            ui.allocate_exact_size(Vec2::new(total_width, height), Sense::click());
138        crate::styles::focus::draw_focus_ring(ui, &response, self.config.rounding);
139
140        // All paint operations wrapped with visibility check
141        if ui.is_rect_visible(rect) {
142            // Background
143            let bg_color = if response.is_pointer_button_down_on() {
144                ui.visuals().widgets.active.bg_fill
145            } else if response.hovered() {
146                ui.visuals().widgets.hovered.bg_fill
147            } else {
148                Color32::TRANSPARENT
149            };
150
151            ui.painter()
152                .rect_filled(rect, self.config.rounding, bg_color);
153
154            // Hover/pressed state
155            let is_hovered = response.hovered();
156            let is_active = response.is_pointer_button_down_on();
157
158            // Icon - using ui.put() for precise positioning
159            let icon_pos = Pos2::new(rect.min.x + padding, rect.center().y - icon_size / 2.0);
160            let icon_rect = Rect::from_min_size(icon_pos, Vec2::splat(icon_size));
161            let icon_color = if is_active || is_hovered {
162                ui.visuals().widgets.hovered.fg_stroke.color
163            } else {
164                ui.visuals().text_color()
165            };
166            self.icon
167                .as_image_tinted(Vec2::splat(icon_size), icon_color)
168                .paint_at(ui, icon_rect);
169
170            // Text color
171            let color = if is_hovered || is_active {
172                ui.visuals().widgets.hovered.fg_stroke.color
173            } else {
174                ui.visuals().text_color()
175            };
176
177            // Text
178            ui.painter().text(
179                Pos2::new(rect.min.x + padding + icon_size + padding, rect.center().y),
180                egui::Align2::LEFT_CENTER,
181                l10n::text(ui.ctx(), self.label),
182                egui::FontId::proportional(ui.active_theme().text.title),
183                color,
184            );
185        }
186
187        let translated_label = l10n::text(ui.ctx(), self.label);
188        response
189            .widget_info(|| WidgetInfo::labeled(Role::Button, ui.is_enabled(), &translated_label));
190        toolbar_tooltip(
191            response,
192            if self.label == "Alert" {
193                "Create alert"
194            } else {
195                self.label
196            },
197            None,
198        )
199    }
200}
201
202/// Text-only button (used for interval dropdown)
203///
204/// Displays text with hover/pressed background.
205pub struct TextButton {
206    /// Button label.
207    pub text: String,
208    /// Help text displayed on hover.
209    pub tooltip: Option<&'static str>,
210    /// Display and layout options for this component.
211    pub config: TopToolbarConfig,
212}
213
214impl TextButton {
215    /// Create a text button with toolbar sizing.
216    pub fn new(text: String, config: &TopToolbarConfig) -> Self {
217        Self {
218            text,
219            tooltip: None,
220            config: config.clone(),
221        }
222    }
223
224    /// Set help text shown on hover.
225    pub fn with_tooltip(mut self, tooltip: &'static str) -> Self {
226        self.tooltip = Some(tooltip);
227        self
228    }
229
230    pub(crate) fn width(&self, ui: &Ui) -> f32 {
231        let galley = ui.painter().layout_no_wrap(
232            l10n::text(ui.ctx(), &self.text),
233            egui::FontId::proportional(ui.active_theme().text.title),
234            Color32::PLACEHOLDER,
235        );
236        galley.size().x + self.config.padding * 2.0
237    }
238
239    /// Show the component and return its response.
240    pub fn show(self, ui: &mut Ui) -> Response {
241        let total_width = self.width(ui);
242        let height = self.config.btn_size;
243
244        let (rect, response) =
245            ui.allocate_exact_size(Vec2::new(total_width, height), Sense::click());
246        crate::styles::focus::draw_focus_ring(ui, &response, self.config.rounding);
247
248        // Background
249        let bg_color = if response.is_pointer_button_down_on() {
250            ui.visuals().widgets.active.bg_fill
251        } else if response.hovered() {
252            ui.visuals().widgets.hovered.bg_fill
253        } else {
254            Color32::TRANSPARENT
255        };
256
257        ui.painter()
258            .rect_filled(rect, self.config.rounding, bg_color);
259
260        // Text
261        ui.painter().text(
262            rect.center(),
263            egui::Align2::CENTER_CENTER,
264            l10n::text(ui.ctx(), &self.text),
265            egui::FontId::proportional(ui.active_theme().text.title),
266            ui.visuals().text_color(),
267        );
268
269        let translated_label = l10n::text(ui.ctx(), self.tooltip.unwrap_or(&self.text));
270        response.widget_info(|| {
271            egui::WidgetInfo::labeled(egui::Role::Button, response.enabled(), &translated_label)
272        });
273        if let Some(tooltip) = self.tooltip {
274            toolbar_tooltip(response, tooltip, None)
275        } else {
276            response
277        }
278    }
279}
280
281/// Vertical separator line
282pub fn separator(ui: &mut Ui) {
283    ui.space_lg();
284    let rect = ui
285        .allocate_exact_size(Vec2::new(stroke::HAIRLINE, icon_sizes::MD), Sense::hover())
286        .0;
287    ui.painter().rect_filled(
288        rect,
289        DESIGN_TOKENS.rounding.none,
290        ui.visuals().widgets.noninteractive.bg_stroke.color,
291    );
292    ui.space_lg();
293}
294
295/// Text + icon dropdown button (Save dropdown, etc.)
296pub struct TextIconButton<'a> {
297    /// Button label.
298    pub text: &'static str,
299    /// Glyph displayed by the component.
300    pub icon: &'a crate::icons::Icon,
301    /// Help text displayed on hover.
302    pub tooltip: &'static str,
303    /// Display and layout options for this component.
304    pub config: TopToolbarConfig,
305}
306
307impl<'a> TextIconButton<'a> {
308    /// Create a text and icon button with hover text and toolbar sizing.
309    pub fn new(
310        text: &'static str,
311        icon: &'a crate::icons::Icon,
312        tooltip: &'static str,
313        config: &TopToolbarConfig,
314    ) -> Self {
315        Self {
316            text,
317            icon,
318            tooltip,
319            config: config.clone(),
320        }
321    }
322
323    pub(crate) fn width(&self, ui: &Ui) -> f32 {
324        if self.text.is_empty() {
325            return self.config.padding * 3.0 + icon_sizes::XS;
326        }
327        let font_id = egui::FontId::proportional(ui.active_theme().text.title);
328        let galley = ui.painter().layout_no_wrap(
329            l10n::text(ui.ctx(), self.text),
330            font_id,
331            Color32::PLACEHOLDER,
332        );
333        galley.rect.width() + self.config.padding * 3.0 + icon_sizes::XS
334    }
335
336    /// Show the component and return its response.
337    pub fn show(self, ui: &mut Ui) -> Response {
338        let padding = self.config.padding;
339        let icon_size = icon_sizes::XS;
340        let font_id = egui::FontId::proportional(ui.active_theme().text.title);
341        let size = Vec2::new(self.width(ui), self.config.btn_size);
342        let (rect, response) = ui.allocate_exact_size(size, Sense::click());
343        crate::styles::focus::draw_focus_ring(ui, &response, self.config.rounding);
344
345        if ui.is_rect_visible(rect) {
346            // Background
347            let bg_color = if response.is_pointer_button_down_on() {
348                ui.visuals().widgets.active.bg_fill
349            } else if response.hovered() {
350                ui.visuals().widgets.hovered.bg_fill
351            } else {
352                Color32::TRANSPARENT
353            };
354
355            ui.painter()
356                .rect_filled(rect, self.config.rounding, bg_color);
357
358            // Text
359            let text_color = if response.hovered() {
360                ui.visuals().widgets.hovered.fg_stroke.color
361            } else {
362                ui.visuals().text_color()
363            };
364            if !self.text.is_empty() {
365                let text_x = rect.min.x + padding;
366                ui.painter().text(
367                    Pos2::new(text_x, rect.center().y),
368                    egui::Align2::LEFT_CENTER,
369                    l10n::text(ui.ctx(), self.text),
370                    font_id,
371                    text_color,
372                );
373            }
374
375            // Icon (chevron)
376            let icon_x = rect.max.x - padding - icon_size;
377            let icon_pos = Pos2::new(icon_x, rect.center().y - icon_size / 2.0);
378            let icon_rect = Rect::from_min_size(icon_pos, Vec2::splat(icon_size));
379            let icon_color = if response.hovered() {
380                ui.visuals().widgets.hovered.fg_stroke.color
381            } else {
382                ui.visuals().text_color()
383            };
384            self.icon
385                .as_image_tinted(Vec2::splat(icon_size), icon_color)
386                .paint_at(ui, icon_rect);
387        }
388
389        let translated_label = l10n::text(ui.ctx(), self.tooltip);
390        response
391            .widget_info(|| WidgetInfo::labeled(Role::Button, ui.is_enabled(), &translated_label));
392        toolbar_tooltip(response, self.tooltip, None)
393    }
394}
395
396/// Neutral colour of the toolbar's group rules and pill outlines.
397pub(crate) fn rule_color(theme: &Theme) -> Color32 {
398    if theme.dark {
399        theme.ui.border.lerp_to_gamma(theme.ui.text, 0.16)
400    } else {
401        theme.ui.panel_bg.lerp_to_gamma(theme.ui.text, 0.085)
402    }
403}
404
405/// Labelled pill at the right end of the toolbar: outlined for Trade, filled in the text colour
406/// for a host call to action.
407pub(crate) struct PillButton<'a> {
408    pub label: &'a str,
409    pub tooltip: Option<&'a str>,
410    pub filled: bool,
411}
412
413impl PillButton<'_> {
414    pub(crate) fn width(&self, ui: &Ui) -> f32 {
415        let font_id = egui::FontId::proportional(ui.active_theme().text.title);
416        let galley = ui.painter().layout_no_wrap(
417            l10n::text(ui.ctx(), self.label),
418            font_id,
419            Color32::PLACEHOLDER,
420        );
421        galley.size().x + 2.0 * ui.active_theme().spacing.xl
422    }
423
424    pub(crate) fn show(self, ui: &mut Ui) -> Response {
425        let theme = ui.active_theme();
426        let size = Vec2::new(self.width(ui), DESIGN_TOKENS.sizing.button_md);
427        let (rect, response) = ui.allocate_exact_size(size, Sense::click());
428        let radius = rect.height() / 2.0;
429        crate::styles::focus::draw_focus_ring(ui, &response, radius);
430
431        if ui.is_rect_visible(rect) {
432            let pressed = response.is_pointer_button_down_on();
433            let hovered = response.hovered();
434            let widgets = &ui.visuals().widgets;
435            let (fill, text) = if self.filled {
436                // Inverse of the panel: dark on light themes, light on dark ones; hover eases
437                // the fill towards the panel.
438                let t = if pressed {
439                    0.3
440                } else if hovered {
441                    0.2
442                } else {
443                    0.0
444                };
445                (
446                    theme.ui.text.lerp_to_gamma(theme.ui.panel_bg, t),
447                    theme.ui.panel_bg,
448                )
449            } else if pressed {
450                (widgets.active.bg_fill, theme.ui.text)
451            } else if hovered {
452                (widgets.hovered.bg_fill, theme.ui.text)
453            } else {
454                (Color32::TRANSPARENT, theme.ui.text)
455            };
456            ui.painter().rect_filled(rect, radius, fill);
457            if !self.filled {
458                ui.painter().rect_stroke(
459                    rect,
460                    radius,
461                    egui::Stroke::new(DESIGN_TOKENS.stroke.hairline, rule_color(&theme)),
462                    egui::StrokeKind::Inside,
463                );
464            }
465            ui.painter().text(
466                rect.center(),
467                egui::Align2::CENTER_CENTER,
468                l10n::text(ui.ctx(), self.label),
469                egui::FontId::proportional(theme.text.title),
470                text,
471            );
472        }
473
474        let translated_label = l10n::text(ui.ctx(), self.label);
475        response
476            .widget_info(|| WidgetInfo::labeled(Role::Button, ui.is_enabled(), &translated_label));
477        match self.tooltip {
478            Some(tooltip) => toolbar_tooltip(response, tooltip, None),
479            None => response,
480        }
481    }
482}
483
484/// Toolbar menu trigger: plain text or an icon, or a filled symbol pill.
485pub(crate) struct Trigger<'a> {
486    pub icon: Option<&'a Icon>,
487    pub label: String,
488    pub name: String,
489    /// The trigger is at least this wide.
490    pub min_width: f32,
491    /// Filled symbol pill with a semibold label and a leading icon.
492    pub filled: bool,
493}
494
495impl Trigger<'_> {
496    fn galley(&self, ui: &Ui, theme: &Theme) -> Arc<Galley> {
497        let mut format = egui::text::TextFormat::simple(
498            egui::FontId::proportional(theme.text.title),
499            theme.ui.text,
500        );
501        if self.filled {
502            format.coords.push("wght", 600.0);
503        }
504        ui.painter()
505            .layout_job(egui::text::LayoutJob::single_section(
506                l10n::text(ui.ctx(), self.label.clone()),
507                format,
508            ))
509    }
510
511    fn width_for(&self, theme: &Theme, label_width: f32) -> f32 {
512        let icon = self.icon.map_or(0.0, |_| icon_sizes::LG);
513        let gap = if icon > 0.0 && !self.label.is_empty() {
514            theme.spacing.sm
515        } else {
516            0.0
517        };
518        (icon + gap + label_width + theme.spacing.lg * 2.0).max(self.min_width)
519    }
520
521    pub(crate) fn show(self, ui: &mut Ui, open: bool) -> Response {
522        let theme = ui.active_theme();
523        let galley = self.galley(ui, &theme);
524        let size = Vec2::new(
525            self.width_for(&theme, galley.size().x),
526            if self.filled {
527                28.0
528            } else {
529                super::config::BUTTON_SIZE
530            },
531        );
532        let (rect, response) = ui.allocate_exact_size(size, Sense::click());
533        crate::styles::focus::draw_focus_ring(
534            ui,
535            &response,
536            if self.filled {
537                rect.height() / 2.0
538            } else {
539                theme.radius.md
540            },
541        );
542
543        if ui.is_rect_visible(rect) {
544            let widgets = &ui.visuals().widgets;
545            let pressed = open || response.is_pointer_button_down_on();
546            let hovered = response.hovered();
547            let fill = if pressed {
548                widgets.active.bg_fill
549            } else if hovered {
550                widgets.hovered.bg_fill
551            } else if self.filled {
552                theme.ui.panel_bg.lerp_to_gamma(theme.ui.text, 0.085)
553            } else {
554                Color32::TRANSPARENT
555            };
556            let icon_color = if pressed || hovered {
557                widgets.hovered.fg_stroke.color
558            } else {
559                theme.ui.text
560            };
561            ui.painter().rect_filled(
562                rect,
563                if self.filled {
564                    rect.height() / 2.0
565                } else {
566                    theme.radius.md
567                },
568                fill,
569            );
570
571            let paint_icon = |icon: &Icon, x: f32, size: f32| {
572                let min = Pos2::new(x, rect.center().y - size / 2.0);
573                icon.as_image_tinted(Vec2::splat(size), icon_color)
574                    .paint_at(ui, Rect::from_min_size(min, Vec2::splat(size)));
575            };
576            let content_width = galley.size().x
577                + self.icon.map_or(0.0, |_| icon_sizes::LG)
578                + if self.icon.is_some() && !self.label.is_empty() {
579                    theme.spacing.sm
580                } else {
581                    0.0
582                };
583            let x = if self.filled {
584                rect.min.x + theme.spacing.lg
585            } else {
586                rect.center().x - content_width / 2.0
587            };
588            let label_x = x + self.icon.map_or(0.0, |_| icon_sizes::LG + theme.spacing.sm);
589            let label_pos = Pos2::new(label_x, rect.center().y - galley.size().y / 2.0);
590            ui.painter().galley(label_pos, galley, theme.ui.text);
591            if let Some(icon) = self.icon {
592                paint_icon(icon, x, icon_sizes::LG);
593            }
594        }
595
596        let translated_label = l10n::text(ui.ctx(), &self.name);
597        response
598            .widget_info(|| WidgetInfo::labeled(Role::Button, ui.is_enabled(), &translated_label));
599        toolbar_tooltip(response, &self.name, None)
600    }
601}
602
603/// Fill of the hover labels, which stay dark in every preset.
604const HOVER_LABEL_FILL: Color32 = Color32::from_gray(28);
605/// Text of the hover labels.
606const HOVER_LABEL_TEXT: Color32 = Color32::from_gray(240);
607/// Key chips of the hover labels, and the rule before them.
608const HOVER_LABEL_KEY: Color32 = Color32::from_gray(99);
609/// Height of a hover label's row, which its 3 px margins bring to 24 px.
610const HOVER_LABEL_ROW: f32 = 18.0;
611/// Text size of the hover labels.
612const HOVER_LABEL_TEXT_SIZE: f32 = 13.0;
613
614/// One run of a hover label, as tall as the row whatever the font's line height.
615fn hover_label_run(ui: &mut Ui, text: String, padding: f32, fill: Color32, color: Color32) {
616    let font = egui::FontId::proportional(HOVER_LABEL_TEXT_SIZE);
617    let galley = ui.painter().layout_no_wrap(text, font, color);
618    let size = Vec2::new(galley.size().x + 2.0 * padding, HOVER_LABEL_ROW);
619    let (rect, response) = ui.allocate_exact_size(size, Sense::hover());
620    response.widget_info(|| WidgetInfo::labeled(Role::Label, true, galley.text()));
621    ui.painter()
622        .rect_filled(rect, ui.active_theme().radius.xs, fill);
623    ui.painter()
624        .galley(rect.center() - galley.size() / 2.0, galley, color);
625}
626
627/// Attach a compact toolbar tooltip, with optional platform-specific shortcut chips.
628///
629/// Uses the normal hover delay and stays below the trigger while there is room.
630pub fn toolbar_tooltip(
631    response: Response,
632    text: &str,
633    shortcut: Option<egui::KeyboardShortcut>,
634) -> Response {
635    let shortcut = shortcut.or_else(|| {
636        let action = match text {
637            "Quick Search" | "Quick search" => crate::config::KeyboardAction::OpenQuickSearch,
638            "Indicators" => crate::config::KeyboardAction::OpenIndicatorSearch,
639            "Create alert" => crate::config::KeyboardAction::CreateAlert,
640            _ => return None,
641        };
642        response
643            .ctx
644            .data(|data| {
645                data.get_temp::<crate::widget::ShortcutMap>(egui::Id::NULL.with("chart_shortcuts"))
646            })
647            .unwrap_or_default()
648            .shortcut(action)
649    });
650    egui::Popup::from_response(&response)
651        .id(response.id.with("toolbar_tooltip"))
652        .kind(egui::PopupKind::Tooltip)
653        .align(egui::RectAlign::BOTTOM)
654        .gap(9.0)
655        .sense(Sense::hover())
656        .interactable(false)
657        .open(response.enabled() && egui::Tooltip::should_show_tooltip(&response, false))
658        .frame(
659            egui::Frame::new()
660                .fill(HOVER_LABEL_FILL)
661                .corner_radius(response.ctx.active_theme().radius.sm)
662                .inner_margin(egui::Margin::symmetric(9, 3)),
663        )
664        .show(|ui| {
665            ui.spacing_mut().item_spacing.x = 4.0;
666            ui.spacing_mut().interact_size.y = HOVER_LABEL_ROW;
667            ui.horizontal(|ui| {
668                let label = l10n::text(ui.ctx(), text);
669                hover_label_run(ui, label, 0.0, Color32::TRANSPARENT, HOVER_LABEL_TEXT);
670                if let Some(shortcut) = shortcut {
671                    // With the row's 4 px spacing, 9 px after the text and 8 px before the chip.
672                    ui.add_space(5.0);
673                    let (rule, _) =
674                        ui.allocate_exact_size(Vec2::new(1.0, HOVER_LABEL_ROW), Sense::hover());
675                    ui.painter().rect_filled(rule, 0.0, HOVER_LABEL_KEY);
676                    ui.add_space(4.0);
677                    let font = egui::FontId::proportional(HOVER_LABEL_TEXT_SIZE);
678                    let keys = shortcut_key_labels(ui.ctx(), &font, &shortcut);
679                    for (index, key) in keys.iter().enumerate() {
680                        if index > 0 {
681                            let plus = "+".to_owned();
682                            hover_label_run(ui, plus, 0.0, Color32::TRANSPARENT, Color32::WHITE);
683                        }
684                        let key = l10n::text(ui.ctx(), key);
685                        hover_label_run(ui, key, 4.0, HOVER_LABEL_KEY, Color32::WHITE);
686                    }
687                }
688            });
689            let x = response
690                .rect
691                .center()
692                .x
693                .clamp(ui.min_rect().left(), ui.min_rect().right());
694            let y = ui.min_rect().top() - 4.0;
695            ui.painter()
696                .with_clip_rect(egui::Rect::EVERYTHING)
697                .add(egui::Shape::convex_polygon(
698                    vec![
699                        egui::pos2(x - 5.0, y),
700                        egui::pos2(x, y - 5.0),
701                        egui::pos2(x + 5.0, y),
702                    ],
703                    HOVER_LABEL_FILL,
704                    egui::Stroke::NONE,
705                ));
706        });
707    response
708}