1pub use egui_command;
34use {
35 egui::{Context, Key, KeyboardShortcut, Modifiers},
36 egui_command::{CommandId, CommandRegistry, CommandSource, CommandTriggered},
37 parking_lot::RwLock,
38 std::{collections::HashMap, sync::Arc},
39};
40
41#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
43pub struct Shortcut {
44 pub key: Key,
45 pub mods: Modifiers,
46}
47
48pub type ShortcutMap<C> = HashMap<Shortcut, C>;
50
51pub struct ShortcutScope<C> {
57 pub name: &'static str,
58 pub shortcuts: ShortcutMap<C>,
59 pub consume: bool,
60}
61
62impl<C> ShortcutScope<C> {
63 pub fn new(name: &'static str, shortcuts: ShortcutMap<C>, consume: bool) -> Self {
65 Self {
66 name,
67 shortcuts,
68 consume,
69 }
70 }
71}
72
73pub struct ShortcutManager<C> {
80 global: Arc<RwLock<ShortcutMap<C>>>,
81 stack: Vec<ShortcutScope<C>>,
82}
83
84impl<C: Clone> ShortcutManager<C> {
85 pub fn new(global: Arc<RwLock<ShortcutMap<C>>>) -> Self {
86 Self {
87 global,
88 stack: Vec::new(),
89 }
90 }
91
92 pub fn push_scope(&mut self, scope: ShortcutScope<C>) { self.stack.push(scope); }
94
95 pub fn pop_scope(&mut self) { self.stack.pop(); }
97
98 pub fn register_global(&mut self, sc: Shortcut, cmd: C) { self.global.write().insert(sc, cmd); }
100
101 pub fn fill_shortcut_hints<R>(&self, registry: &mut CommandRegistry<R>)
111 where
112 C: Into<CommandId> + Copy,
113 R: Copy + std::hash::Hash + Eq + Into<CommandId>,
114 {
115 let global = self.global.read();
116 for (shortcut, cmd) in global.iter() {
117 let id: CommandId = (*cmd).into();
118 if let Some(spec) = registry.spec_by_id_mut(id) {
119 spec.shortcut_hint = Some(format_shortcut(shortcut));
120 }
121 }
122 }
123
124 pub fn dispatch(&self, ctx: &Context) -> Vec<CommandTriggered>
132 where
133 C: Into<CommandId>,
134 {
135 if ctx.wants_keyboard_input() {
136 return Vec::new();
137 }
138
139 self.dispatch_raw_inner(ctx, None)
140 .into_iter()
141 .map(|cmd| CommandTriggered::new(cmd.into(), CommandSource::Keyboard))
142 .collect()
143 }
144
145 pub fn dispatch_raw_with_extra(&self, ctx: &Context, extra: Option<&ShortcutMap<C>>) -> Vec<C> {
153 if ctx.wants_keyboard_input() {
154 return Vec::new();
155 }
156
157 self.dispatch_raw_inner(ctx, extra)
158 }
159
160 pub fn dispatch_raw(&self, ctx: &Context) -> Vec<C> {
164 if ctx.wants_keyboard_input() {
165 return Vec::new();
166 }
167
168 self.dispatch_raw_inner(ctx, None)
169 }
170
171 pub fn try_shortcut(&self, ctx: &Context, sc: Shortcut) -> Option<C> {
180 let global = self.global.read();
181 let cmd = global.get(&sc)?.clone();
182 if ctx.input_mut(|i| i.consume_shortcut(&sc.to_keyboard_shortcut())) {
183 Some(cmd)
184 } else {
185 None
186 }
187 }
188
189 fn dispatch_raw_inner(&self, ctx: &Context, extra: Option<&ShortcutMap<C>>) -> Vec<C> {
195 let mut triggered: Vec<C> = Vec::new();
196 let global = self.global.read();
197
198 ctx.input_mut(|input| {
199 let mut consumed: Vec<KeyboardShortcut> = Vec::new();
200
201 for event in &input.events {
202 let egui::Event::Key {
203 key,
204 pressed: true,
205 repeat: false,
206 modifiers,
207 ..
208 } = event
209 else {
210 continue;
211 };
212
213 if let Some(extra_map) = extra
215 && let Some((shortcut, cmd)) = best_shortcut_match(extra_map, *key, *modifiers)
216 {
217 triggered.push(cmd.clone());
218 consumed.push(shortcut.to_keyboard_shortcut());
219 continue;
220 }
221
222 let mut stop_propagation = false;
223 for scope in self.stack.iter().rev() {
224 if let Some((shortcut, cmd)) = best_shortcut_match(&scope.shortcuts, *key, *modifiers) {
225 triggered.push(cmd.clone());
226 consumed.push(shortcut.to_keyboard_shortcut());
227 if scope.consume {
228 stop_propagation = true;
229 break;
230 }
231 }
232 }
233 if stop_propagation {
234 continue;
235 }
236
237 if let Some((shortcut, cmd)) = best_shortcut_match(&global, *key, *modifiers) {
239 triggered.push(cmd.clone());
240 consumed.push(shortcut.to_keyboard_shortcut());
241 }
242 }
243
244 for shortcut in consumed {
245 input.consume_shortcut(&shortcut);
246 }
247 });
248
249 triggered
250 }
251}
252
253fn best_shortcut_match<C>(
254 map: &ShortcutMap<C>,
255 key: Key,
256 pressed_modifiers: Modifiers,
257) -> Option<(Shortcut, &C)> {
258 map.iter()
259 .filter(|(shortcut, _)| shortcut.key == key && pressed_modifiers.matches_logically(shortcut.mods))
260 .max_by_key(|(shortcut, _)| shortcut.specificity())
261 .map(|(shortcut, command)| (*shortcut, command))
262}
263
264fn format_shortcut(sc: &Shortcut) -> String {
265 let mut parts: Vec<String> = Vec::new();
266 if sc.mods.ctrl { parts.push("Ctrl".into()); }
267 if sc.mods.alt { parts.push("Alt".into()); }
268 if sc.mods.shift { parts.push("Shift".into()); }
269 if sc.mods.command { parts.push("Cmd".into()); }
270 if sc.mods.mac_cmd { parts.push("Meta".into()); }
271 parts.push(format!("{:?}", sc.key));
272 parts.join("+")
273}
274
275impl Shortcut {
276 fn specificity(self) -> u8 {
277 self.mods.alt as u8
278 + self.mods.shift as u8
279 + self.mods.ctrl as u8
280 + self.mods.command as u8
281 + self.mods.mac_cmd as u8
282 }
283
284 fn to_keyboard_shortcut(self) -> KeyboardShortcut {
285 KeyboardShortcut::new(self.mods, self.key)
286 }
287}
288
289pub fn shortcut(sc: &str) -> Shortcut {
293 let mut mods = Modifiers::default();
294 let mut key = None;
295
296 for part in sc.split('+') {
297 let part = part.trim();
298 match part.to_uppercase().as_str() {
299 "CTRL" | "CONTROL" => mods.ctrl = true,
300 "ALT" => mods.alt = true,
301 "SHIFT" => mods.shift = true,
302 "META" => mods.mac_cmd = true,
303 "CMD" | "COMMAND" => mods.command = true,
304 _ => key = Key::from_name(part),
307 }
308 }
309
310 Shortcut {
311 key: key.expect("Invalid key in shortcut string"),
312 mods,
313 }
314}
315
316#[macro_export]
326macro_rules! shortcut_map {
327 ($($key:expr => $cmd:expr),* $(,)?) => {{
328 #[allow(unused_mut)]
329 let mut map = $crate::ShortcutMap::new();
330 $(map.insert($crate::shortcut($key), $cmd);)*
331 map
332 }};
333}
334
335#[cfg(test)]
336mod tests {
337 use {
338 super::*,
339 egui::{Event, Key, Modifiers, RawInput},
340 };
341
342 fn key_event(key: Key, modifiers: Modifiers) -> Event {
343 Event::Key {
344 key,
345 physical_key: None,
346 pressed: true,
347 repeat: false,
348 modifiers,
349 }
350 }
351
352 fn dispatch_raw_events(manager: &ShortcutManager<u32>, events: Vec<Event>) -> Vec<u32> {
353 let ctx = Context::default();
354 let mut triggered = None;
355
356 let _ = ctx.run(
357 RawInput {
358 events,
359 ..RawInput::default()
360 },
361 |ctx| {
362 triggered = Some(manager.dispatch_raw(ctx));
363 },
364 );
365
366 triggered.expect("dispatch should run exactly once")
367 }
368
369 #[test]
370 fn shortcut_single_key() {
371 let sc = shortcut("F1");
372 assert_eq!(sc.key, Key::F1);
373 assert_eq!(sc.mods, Modifiers::default());
374 }
375
376 #[test]
377 fn shortcut_ctrl_s() {
378 let sc = shortcut("Ctrl+S");
379 assert_eq!(sc.key, Key::S);
380 assert!(sc.mods.ctrl);
381 assert!(!sc.mods.alt);
382 assert!(!sc.mods.shift);
383 }
384
385 #[test]
386 fn shortcut_alt_shift_x() {
387 let sc = shortcut("Alt+Shift+X");
388 assert_eq!(sc.key, Key::X);
389 assert!(sc.mods.alt);
390 assert!(sc.mods.shift);
391 assert!(!sc.mods.ctrl);
392 }
393
394 #[test]
395 fn shortcut_control_alias() {
396 let sc = shortcut("Control+A");
397 assert!(sc.mods.ctrl);
398 assert_eq!(sc.key, Key::A);
399 }
400
401 #[test]
402 fn shortcut_command_sets_logical_command_modifier() {
403 let sc = shortcut("Cmd+S");
404 assert_eq!(sc.key, Key::S);
405 assert!(sc.mods.command);
406 assert!(!sc.mods.mac_cmd);
407 }
408
409 #[test]
410 #[should_panic]
411 fn shortcut_invalid_key_panics() { shortcut("Ctrl+NotAKey"); }
412
413 #[test]
414 fn shortcut_map_macro_builds_correctly() {
415 let map = shortcut_map![
416 "F1" => 1u32,
417 "F2" => 2u32,
418 ];
419 assert_eq!(map.get(&shortcut("F1")), Some(&1u32));
420 assert_eq!(map.get(&shortcut("F2")), Some(&2u32));
421 assert_eq!(map.get(&shortcut("F3")), None);
422 }
423
424 #[test]
425 fn shortcut_map_macro_empty() {
426 let map: ShortcutMap<u32> = shortcut_map![];
427 assert!(map.is_empty());
428 }
429
430 #[test]
431 fn shortcut_equality_and_hash() {
432 use std::collections::HashMap;
433 let mut m: HashMap<Shortcut, &str> = HashMap::new();
434 m.insert(shortcut("Ctrl+S"), "save");
435 assert_eq!(m[&shortcut("Ctrl+S")], "save");
436 assert!(!m.contains_key(&shortcut("Ctrl+Z")));
437 }
438
439 #[test]
440 fn non_consuming_scope_still_allows_global_fallback() {
441 let global = Arc::new(RwLock::new(shortcut_map!["Ctrl+S" => 1u32]));
442 let mut manager = ShortcutManager::new(global);
443 manager.push_scope(ShortcutScope::new(
444 "editor",
445 shortcut_map!["Ctrl+S" => 2u32],
446 false,
447 ));
448
449 let triggered = dispatch_raw_events(&manager, vec![key_event(Key::S, Modifiers::CTRL)]);
450 assert_eq!(triggered, vec![2, 1]);
451 }
452
453 #[test]
454 fn consuming_scope_blocks_global_fallback() {
455 let global = Arc::new(RwLock::new(shortcut_map!["Ctrl+S" => 1u32]));
456 let mut manager = ShortcutManager::new(global);
457 manager.push_scope(ShortcutScope::new(
458 "editor",
459 shortcut_map!["Ctrl+S" => 2u32],
460 true,
461 ));
462
463 let triggered = dispatch_raw_events(&manager, vec![key_event(Key::S, Modifiers::CTRL)]);
464 assert_eq!(triggered, vec![2]);
465 }
466
467 #[test]
468 fn logical_command_shortcut_matches_command_input() {
469 let global = Arc::new(RwLock::new(shortcut_map!["Cmd+S" => 7u32]));
470 let manager = ShortcutManager::new(global);
471
472 let triggered = dispatch_raw_events(&manager, vec![key_event(Key::S, Modifiers::COMMAND)]);
473 assert_eq!(triggered, vec![7]);
474 }
475
476 #[test]
477 fn more_specific_shortcut_wins_with_logical_matching() {
478 let global = Arc::new(RwLock::new(shortcut_map![
479 "Ctrl+S" => 1u32,
480 "Ctrl+Shift+S" => 2u32,
481 ]));
482 let manager = ShortcutManager::new(global);
483
484 let triggered = dispatch_raw_events(
485 &manager,
486 vec![key_event(Key::S, Modifiers::CTRL | Modifiers::SHIFT)],
487 );
488 assert_eq!(triggered, vec![2]);
489 }
490
491 #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
492 enum TestCmd { Save, Help, Quit }
493
494 impl From<TestCmd> for egui_command::CommandId {
495 fn from(c: TestCmd) -> Self { egui_command::CommandId::new(c) }
496 }
497
498 #[test]
499 fn fill_shortcut_hints_writes_to_registered_commands() {
500 let global = Arc::new(RwLock::new(shortcut_map![
501 "Ctrl+S" => TestCmd::Save,
502 "F1" => TestCmd::Help,
503 ]));
504 let manager = ShortcutManager::new(global);
505
506 let mut reg = egui_command::CommandRegistry::new()
507 .with(TestCmd::Save, egui_command::CommandSpec::new(TestCmd::Save.into(), "Save"))
508 .with(TestCmd::Help, egui_command::CommandSpec::new(TestCmd::Help.into(), "Help"))
509 .with(TestCmd::Quit, egui_command::CommandSpec::new(TestCmd::Quit.into(), "Quit"));
510
511 manager.fill_shortcut_hints(&mut reg);
512
513 let save_hint = reg.spec(TestCmd::Save).unwrap().shortcut_hint.as_deref();
514 let help_hint = reg.spec(TestCmd::Help).unwrap().shortcut_hint.as_deref();
515 let quit_hint = reg.spec(TestCmd::Quit).unwrap().shortcut_hint.as_deref();
516
517 assert!(save_hint.is_some(), "Save should have a shortcut hint");
518 assert!(save_hint.unwrap().contains("S"), "Save hint should mention S key");
519 assert!(help_hint.is_some(), "Help should have a shortcut hint");
520 assert!(help_hint.unwrap().contains("F1"), "Help hint should contain F1");
521 assert!(quit_hint.is_none(), "Quit has no binding, hint should be None");
522 }
523
524 #[test]
525 fn fill_shortcut_hints_unregistered_command_is_skipped() {
526 let global = Arc::new(RwLock::new(shortcut_map!["F9" => TestCmd::Quit]));
527 let manager = ShortcutManager::new(global);
528
529 let mut reg = egui_command::CommandRegistry::new()
530 .with(TestCmd::Save, egui_command::CommandSpec::new(TestCmd::Save.into(), "Save"));
531
532 manager.fill_shortcut_hints(&mut reg);
533
534 assert!(reg.spec(TestCmd::Save).unwrap().shortcut_hint.is_none());
535 }
536}