egui_charts/ui/dialogs/keyboard_shortcuts/
mod.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18#[non_exhaustive]
19pub enum KeyboardShortcutsAction {
20 None,
22 Close,
24}
25
26pub struct KeyboardShortcutsDialog {
28 pub is_open: bool,
30}
31
32impl Default for KeyboardShortcutsDialog {
33 fn default() -> Self {
34 Self::new()
35 }
36}
37
38impl KeyboardShortcutsDialog {
39 pub fn new() -> Self {
41 Self { is_open: false }
42 }
43
44 pub fn open(&mut self) {
46 self.is_open = true;
47 }
48
49 pub fn close(&mut self) {
51 self.is_open = false;
52 }
53
54 pub fn toggle(&mut self) {
56 self.is_open = !self.is_open;
57 }
58
59 pub fn show(&mut self, ctx: &Context) -> KeyboardShortcutsAction {
61 self.show_with_shortcuts(ctx, &ShortcutMap::default())
62 }
63
64 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 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 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 .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
186pub(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
208fn 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 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}