Skip to main content

egui_charts/ui/dialogs/keyboard_shortcuts/
mod.rs

1//! Keyboard shortcuts help dialog.
2//!
3//! Lists the handled keyboard shortcuts by category. The host opens it on `?` or `F1`.
4
5use crate::localization as l10n;
6use egui::{
7    Color32, Context, FontId, Frame, Id, Key, KeyboardShortcut, Margin, Modifiers, RichText, Role,
8    ScrollArea, Stroke, Ui, WidgetInfo,
9};
10
11use crate::theme::{Theme, ThemeExt};
12use crate::tokens::DESIGN_TOKENS;
13use crate::ui_kit::dialog::{DialogFrame, dialog_header};
14use crate::widget::ShortcutMap;
15
16/// Action returned by the keyboard shortcuts dialog
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18#[non_exhaustive]
19pub enum KeyboardShortcutsAction {
20    /// No action
21    None,
22    /// Dialog was closed
23    Close,
24}
25
26/// Keyboard shortcuts help dialog
27pub struct KeyboardShortcutsDialog {
28    /// Whether the dialog is currently open
29    pub is_open: bool,
30}
31
32impl Default for KeyboardShortcutsDialog {
33    fn default() -> Self {
34        Self::new()
35    }
36}
37
38impl KeyboardShortcutsDialog {
39    /// Create a [`KeyboardShortcutsDialog`] with default settings.
40    pub fn new() -> Self {
41        Self { is_open: false }
42    }
43
44    /// Open the dialog
45    pub fn open(&mut self) {
46        self.is_open = true;
47    }
48
49    /// Close the dialog
50    pub fn close(&mut self) {
51        self.is_open = false;
52    }
53
54    /// Toggle the dialog open/closed
55    pub fn toggle(&mut self) {
56        self.is_open = !self.is_open;
57    }
58
59    /// Show the dialog. Returns the action taken.
60    pub fn show(&mut self, ctx: &Context) -> KeyboardShortcutsAction {
61        self.show_with_shortcuts(ctx, &ShortcutMap::default())
62    }
63
64    /// Show only the host's bound shortcuts, using the same map as its chart.
65    pub fn show_with_shortcuts(
66        &mut self,
67        ctx: &Context,
68        shortcuts: &ShortcutMap,
69    ) -> KeyboardShortcutsAction {
70        if !self.is_open {
71            return KeyboardShortcutsAction::None;
72        }
73
74        let blocked_now = ctx.text_edit_focused() || egui::Popup::is_any_open(ctx);
75        let mut action = KeyboardShortcutsAction::None;
76
77        DialogFrame::with_width(
78            "Keyboard Shortcuts",
79            DESIGN_TOKENS.sizing.dialog.default_width,
80        )
81        .show(ctx, |ui| {
82            action = self.render_contents(ui, shortcuts);
83        });
84
85        // Escape belongs to an open popup or a focused text field first. egui closes both on
86        // Escape before this runs, so their state is read from the previous frame. Consuming the
87        // key keeps whatever reads input after the dialog from acting on the same press.
88        let blocked_id = Id::NULL.with("keyboard_shortcuts_escape_blocked");
89        let blocked = ctx
90            .data(|d| d.get_temp::<bool>(blocked_id))
91            .unwrap_or(false);
92        if !blocked
93            && !blocked_now
94            && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape))
95        {
96            self.is_open = false;
97            action = KeyboardShortcutsAction::Close;
98        }
99        let blocked = ctx.text_edit_focused() || egui::Popup::is_any_open(ctx);
100        ctx.data_mut(|d| d.insert_temp(blocked_id, blocked));
101
102        action
103    }
104
105    fn render_contents(&mut self, ui: &mut Ui, shortcuts: &ShortcutMap) -> KeyboardShortcutsAction {
106        let mut action = KeyboardShortcutsAction::None;
107        let theme = ui.active_theme();
108
109        if dialog_header(ui, "Keyboard Shortcuts") {
110            action = KeyboardShortcutsAction::Close;
111            self.is_open = false;
112        }
113        // The chart reads its keys only while it has keyboard focus.
114        ui.label(
115            RichText::new(l10n::text(
116                ui.ctx(),
117                "Click the chart to use the chart keys.",
118            ))
119            .size(theme.text.small)
120            .color(ui.visuals().weak_text_color()),
121        );
122        ui.add_space(theme.spacing.lg);
123
124        let categories = shortcuts.by_category();
125        let font = FontId::proportional(theme.text.body);
126        let labels: Vec<_> = categories
127            .iter()
128            .flat_map(|(_, actions)| actions)
129            .map(|action| l10n::text(ui.ctx(), action.label()))
130            .collect();
131        let widest = ui.fonts_mut(|f| {
132            labels
133                .into_iter()
134                .map(|label| {
135                    f.layout_no_wrap(label, font.clone(), Color32::PLACEHOLDER)
136                        .size()
137                        .x
138                })
139                .fold(0.0, f32::max)
140        });
141        let label_w = (((widest + theme.spacing.xxl) / 4.0).ceil() * 4.0)
142            .min((ui.available_width() - theme.spacing.sm) / 2.0);
143
144        ui.scope(|ui| {
145            ui.spacing_mut().scroll = egui::style::ScrollStyle::solid();
146            ScrollArea::vertical()
147                .auto_shrink([false, true])
148                .max_height(ui.available_height())
149                .show(ui, |ui| {
150                    for (idx, (name, actions)) in categories.iter().enumerate() {
151                        if idx > 0 {
152                            ui.add_space(theme.spacing.xxl - ui.spacing().item_spacing.y);
153                        }
154                        ui.label(
155                            RichText::new(l10n::text(ui.ctx(), *name))
156                                .size(theme.text.small)
157                                .strong()
158                                .color(theme.ui.text_secondary),
159                        );
160                        egui::Grid::new(("shortcuts", idx))
161                            .num_columns(2)
162                            .spacing([theme.spacing.sm, theme.spacing.sm])
163                            // With the spacing, a 24 px row pitch.
164                            .min_row_height(theme.spacing.xxl + theme.spacing.sm)
165                            .min_col_width(label_w)
166                            .max_col_width(label_w)
167                            .show(ui, |ui| {
168                                for action in actions {
169                                    ui.label(
170                                        RichText::new(l10n::text(ui.ctx(), action.label()))
171                                            .size(theme.text.body),
172                                    );
173                                    let keys: Vec<_> = shortcuts.keys_for(*action).collect();
174                                    ui.horizontal_wrapped(|ui| key_badges(ui, &theme, &keys));
175                                    ui.end_row();
176                                }
177                            });
178                    }
179                });
180        });
181
182        action
183    }
184}
185
186/// Labels of a shortcut's keys: its modifiers, then the key's symbol where `font` has it.
187///
188/// `format_shortcut` spells every key out ("Plus", "PageUp") with the bundled fonts.
189pub(crate) fn shortcut_key_labels(
190    ctx: &Context,
191    font: &FontId,
192    shortcut: &KeyboardShortcut,
193) -> Vec<String> {
194    let symbol = shortcut.logical_key.symbol_or_name();
195    let key = if ctx.fonts_mut(|f| f.has_glyphs(font, symbol)) {
196        symbol
197    } else {
198        shortcut.logical_key.name()
199    };
200    ctx.format_modifiers(shortcut.modifiers)
201        .split('+')
202        .filter(|modifier| !modifier.is_empty())
203        .chain([key])
204        .map(str::to_owned)
205        .collect()
206}
207
208/// One badge per key combination, joined by a muted "or".
209fn key_badges(ui: &mut Ui, theme: &Theme, shortcuts: &[KeyboardShortcut]) {
210    for (idx, shortcut) in shortcuts.iter().enumerate() {
211        if idx > 0 {
212            ui.label(
213                RichText::new(l10n::text(ui.ctx(), "or"))
214                    .size(theme.text.small)
215                    .color(ui.visuals().weak_text_color()),
216            );
217        }
218        // Screen readers get the name.
219        let font = FontId::proportional(theme.text.small);
220        let text = shortcut_key_labels(ui.ctx(), &font, shortcut).join("+");
221        let name = ui.ctx().format_shortcut(shortcut);
222        Frame::new()
223            .fill(theme.ui.panel_bg_secondary)
224            .stroke(Stroke::new(1.0, theme.ui.border_control))
225            .corner_radius(theme.radius.sm)
226            .inner_margin(Margin::symmetric(
227                theme.spacing.sm as i8,
228                theme.spacing.xs as i8,
229            ))
230            .show(ui, |ui| {
231                let translated_label = l10n::text(ui.ctx(), &name);
232                ui.add(
233                    egui::Label::new(
234                        RichText::new(l10n::text(ui.ctx(), text))
235                            .size(theme.text.small)
236                            .color(theme.ui.text),
237                    )
238                    .wrap_mode(egui::TextWrapMode::Extend),
239                )
240                .widget_info(|| WidgetInfo::labeled(Role::Label, true, &translated_label));
241            });
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn a_popup_opened_this_frame_gets_escape_before_the_dialog() {
251        let ctx = Context::default();
252        let mut dialog = KeyboardShortcutsDialog::new();
253        dialog.open();
254        ctx.run_ui(
255            egui::RawInput {
256                events: vec![egui::Event::Key {
257                    key: Key::Escape,
258                    physical_key: None,
259                    pressed: true,
260                    repeat: false,
261                    modifiers: Modifiers::NONE,
262                }],
263                ..Default::default()
264            },
265            |ui| {
266                egui::Popup::open_id(ui.ctx(), Id::NULL.with("test popup"));
267                assert_eq!(dialog.show(ui.ctx()), KeyboardShortcutsAction::None);
268                assert!(dialog.is_open);
269                assert!(ui.input(|i| i.key_pressed(Key::Escape)));
270            },
271        )
272        .drop_without_applying_deltas();
273    }
274}