1use core::f32;
2use egui::{KeyboardShortcut, ModifierNames, Modifiers, Vec2};
3use eyre::Result;
4use serde::{Deserialize, Deserializer, Serialize};
5
6use crate::SystemState;
7use crate::message::{Message, MessageTarget};
8use crate::wave_data::{PER_SCROLL_EVENT, SCROLL_EVENTS_PER_PAGE};
9
10const NUMBER_OF_SHORTCUTS: usize = 32; #[derive(Clone, Copy, Debug)]
14pub enum ShortcutAction {
15 OpenFile,
16 SwitchFile,
17 Redo,
18 Undo,
19 ToggleSidePanel,
20 ToggleToolbar,
21 GoToEnd,
22 GoToStart,
23 SaveStateFile,
24 GoToTop,
25 GoToBottom,
26 ItemFocus,
27 GroupNew,
28 SelectAll,
29 SelectToggle,
30 ReloadWaveform,
31 ZoomIn,
32 ZoomOut,
33 UiZoomIn,
34 UiZoomOut,
35 ScrollUp,
36 ScrollDown,
37 DeleteSelected,
38 MarkerAdd,
39 ToggleMenu,
40 ShowCommandPrompt,
41 RenameItem,
42 DividerAdd,
43 ZoomToFit,
44 ZoomToCursor,
45 GoToTime,
46 FocusVariableNameFilter,
47}
48
49#[derive(Clone, Debug)]
51struct DispatchEntry {
52 action: ShortcutAction,
53 priority: u8,
54}
55
56#[derive(Clone, Debug, Serialize, Deserialize)]
57pub struct SurferShortcuts {
58 #[serde(with = "keyboard_shortcuts_serde")]
59 pub open_file: Vec<KeyboardShortcut>,
60 #[serde(with = "keyboard_shortcuts_serde")]
61 pub switch_file: Vec<KeyboardShortcut>,
62 #[serde(with = "keyboard_shortcuts_serde")]
63 pub undo: Vec<KeyboardShortcut>,
64 #[serde(with = "keyboard_shortcuts_serde")]
65 pub redo: Vec<KeyboardShortcut>,
66 #[serde(with = "keyboard_shortcuts_serde")]
67 pub toggle_side_panel: Vec<KeyboardShortcut>,
68 #[serde(with = "keyboard_shortcuts_serde")]
69 pub toggle_toolbar: Vec<KeyboardShortcut>,
70 #[serde(with = "keyboard_shortcuts_serde")]
71 pub goto_end: Vec<KeyboardShortcut>,
72 #[serde(with = "keyboard_shortcuts_serde")]
73 pub goto_start: Vec<KeyboardShortcut>,
74 #[serde(with = "keyboard_shortcuts_serde")]
75 pub save_state_file: Vec<KeyboardShortcut>,
76 #[serde(with = "keyboard_shortcuts_serde")]
77 pub goto_top: Vec<KeyboardShortcut>,
78 #[serde(with = "keyboard_shortcuts_serde")]
79 pub goto_bottom: Vec<KeyboardShortcut>,
80 #[serde(with = "keyboard_shortcuts_serde")]
81 pub group_new: Vec<KeyboardShortcut>,
82 #[serde(with = "keyboard_shortcuts_serde")]
83 pub item_focus: Vec<KeyboardShortcut>,
84 #[serde(with = "keyboard_shortcuts_serde")]
85 pub select_all: Vec<KeyboardShortcut>,
86 #[serde(with = "keyboard_shortcuts_serde")]
87 pub select_toggle: Vec<KeyboardShortcut>,
88 #[serde(with = "keyboard_shortcuts_serde")]
89 pub reload_waveform: Vec<KeyboardShortcut>,
90 #[serde(with = "keyboard_shortcuts_serde")]
91 pub zoom_in: Vec<KeyboardShortcut>,
92 #[serde(with = "keyboard_shortcuts_serde")]
93 pub zoom_out: Vec<KeyboardShortcut>,
94 #[serde(with = "keyboard_shortcuts_serde")]
95 pub ui_zoom_in: Vec<KeyboardShortcut>,
96 #[serde(with = "keyboard_shortcuts_serde")]
97 pub ui_zoom_out: Vec<KeyboardShortcut>,
98 #[serde(with = "keyboard_shortcuts_serde")]
99 pub scroll_up: Vec<KeyboardShortcut>,
100 #[serde(with = "keyboard_shortcuts_serde")]
101 pub scroll_down: Vec<KeyboardShortcut>,
102 #[serde(with = "keyboard_shortcuts_serde")]
103 pub delete_selected: Vec<KeyboardShortcut>,
104 #[serde(with = "keyboard_shortcuts_serde")]
105 pub marker_add: Vec<KeyboardShortcut>,
106 #[serde(with = "keyboard_shortcuts_serde")]
107 pub toggle_menu: Vec<KeyboardShortcut>,
108 #[serde(with = "keyboard_shortcuts_serde")]
109 pub show_command_prompt: Vec<KeyboardShortcut>,
110 #[serde(with = "keyboard_shortcuts_serde")]
111 pub rename_item: Vec<KeyboardShortcut>,
112 #[serde(with = "keyboard_shortcuts_serde")]
113 pub divider_add: Vec<KeyboardShortcut>,
114 #[serde(with = "keyboard_shortcuts_serde")]
115 pub zoom_to_fit: Vec<KeyboardShortcut>,
116 #[serde(with = "keyboard_shortcuts_serde")]
117 pub zoom_to_cursor: Vec<KeyboardShortcut>,
118 #[serde(with = "keyboard_shortcuts_serde")]
119 pub go_to_time: Vec<KeyboardShortcut>,
120 #[serde(with = "keyboard_shortcuts_serde")]
121 pub focus_variable_name_filter: Vec<KeyboardShortcut>,
122
123 #[serde(skip)]
124 cached_dispatch_table: Vec<DispatchEntry>,
125}
126
127pub fn deserialize_shortcuts<'de, D>(deserializer: D) -> Result<SurferShortcuts, D::Error>
128where
129 D: Deserializer<'de>,
130{
131 let mut shortcuts = SurferShortcuts::deserialize(deserializer)?;
132 shortcuts.cached_dispatch_table = shortcuts.build_dispatch_table();
133 Ok(shortcuts)
134}
135
136impl SurferShortcuts {
137 #[must_use]
138 pub fn format_shortcut(&self, action: ShortcutAction) -> String {
139 #[cfg(any(not(target_os = "macos"), test))]
142 let is_mac = false;
143 #[cfg(all(target_os = "macos", not(test)))]
144 let is_mac = true;
145 self.shortcuts_for_action(action)
146 .iter()
147 .map(|kb| kb.format(&ModifierNames::NAMES, is_mac))
148 .collect::<Vec<String>>()
149 .join("/")
150 }
151
152 fn build_dispatch_table(&self) -> Vec<DispatchEntry> {
153 let mut dispatch_table = Vec::with_capacity(NUMBER_OF_SHORTCUTS);
155
156 dispatch_table.extend_from_slice(&[
158 DispatchEntry {
159 action: ShortcutAction::OpenFile,
160 priority: modifier_priority(&self.open_file),
161 },
162 DispatchEntry {
163 action: ShortcutAction::SwitchFile,
164 priority: modifier_priority(&self.switch_file),
165 },
166 DispatchEntry {
167 action: ShortcutAction::Redo,
168 priority: modifier_priority(&self.redo),
169 },
170 DispatchEntry {
171 action: ShortcutAction::Undo,
172 priority: modifier_priority(&self.undo),
173 },
174 DispatchEntry {
175 action: ShortcutAction::ToggleSidePanel,
176 priority: modifier_priority(&self.toggle_side_panel),
177 },
178 DispatchEntry {
179 action: ShortcutAction::ToggleToolbar,
180 priority: modifier_priority(&self.toggle_toolbar),
181 },
182 DispatchEntry {
183 action: ShortcutAction::GoToEnd,
184 priority: modifier_priority(&self.goto_end),
185 },
186 DispatchEntry {
187 action: ShortcutAction::GoToStart,
188 priority: modifier_priority(&self.goto_start),
189 },
190 DispatchEntry {
191 action: ShortcutAction::SaveStateFile,
192 priority: modifier_priority(&self.save_state_file),
193 },
194 DispatchEntry {
195 action: ShortcutAction::GoToTop,
196 priority: modifier_priority(&self.goto_top),
197 },
198 DispatchEntry {
199 action: ShortcutAction::GoToBottom,
200 priority: modifier_priority(&self.goto_bottom),
201 },
202 DispatchEntry {
203 action: ShortcutAction::GroupNew,
204 priority: modifier_priority(&self.group_new),
205 },
206 DispatchEntry {
207 action: ShortcutAction::ItemFocus,
208 priority: modifier_priority(&self.item_focus),
209 },
210 DispatchEntry {
211 action: ShortcutAction::SelectAll,
212 priority: modifier_priority(&self.select_all),
213 },
214 DispatchEntry {
215 action: ShortcutAction::SelectToggle,
216 priority: modifier_priority(&self.select_toggle),
217 },
218 DispatchEntry {
219 action: ShortcutAction::ReloadWaveform,
220 priority: modifier_priority(&self.reload_waveform),
221 },
222 DispatchEntry {
223 action: ShortcutAction::ZoomIn,
224 priority: modifier_priority(&self.zoom_in),
225 },
226 DispatchEntry {
227 action: ShortcutAction::ZoomOut,
228 priority: modifier_priority(&self.zoom_out),
229 },
230 DispatchEntry {
231 action: ShortcutAction::UiZoomIn,
232 priority: modifier_priority(&self.ui_zoom_in),
233 },
234 DispatchEntry {
235 action: ShortcutAction::UiZoomOut,
236 priority: modifier_priority(&self.ui_zoom_out),
237 },
238 DispatchEntry {
239 action: ShortcutAction::ScrollUp,
240 priority: modifier_priority(&self.scroll_up),
241 },
242 DispatchEntry {
243 action: ShortcutAction::ScrollDown,
244 priority: modifier_priority(&self.scroll_down),
245 },
246 DispatchEntry {
247 action: ShortcutAction::DeleteSelected,
248 priority: modifier_priority(&self.delete_selected),
249 },
250 DispatchEntry {
251 action: ShortcutAction::MarkerAdd,
252 priority: modifier_priority(&self.marker_add),
253 },
254 DispatchEntry {
255 action: ShortcutAction::ToggleMenu,
256 priority: modifier_priority(&self.toggle_menu),
257 },
258 DispatchEntry {
259 action: ShortcutAction::ShowCommandPrompt,
260 priority: modifier_priority(&self.show_command_prompt),
261 },
262 DispatchEntry {
263 action: ShortcutAction::RenameItem,
264 priority: modifier_priority(&self.rename_item),
265 },
266 DispatchEntry {
267 action: ShortcutAction::DividerAdd,
268 priority: modifier_priority(&self.divider_add),
269 },
270 DispatchEntry {
271 action: ShortcutAction::ZoomToFit,
272 priority: modifier_priority(&self.zoom_to_fit),
273 },
274 DispatchEntry {
275 action: ShortcutAction::ZoomToCursor,
276 priority: modifier_priority(&self.zoom_to_cursor),
277 },
278 DispatchEntry {
279 action: ShortcutAction::GoToTime,
280 priority: modifier_priority(&self.go_to_time),
281 },
282 DispatchEntry {
283 action: ShortcutAction::FocusVariableNameFilter,
284 priority: modifier_priority(&self.focus_variable_name_filter),
285 },
286 ]);
287
288 debug_assert!(
289 dispatch_table.len() == NUMBER_OF_SHORTCUTS,
290 "Dispatch table length does not match the number of defined shortcut actions. Update the NUMBER_OF_SHORTCUTS constant accordingly."
291 );
292
293 dispatch_table.sort_by_key(|entry| entry.priority);
295 dispatch_table
296 }
297
298 fn shortcuts_for_action(&self, action: ShortcutAction) -> &[KeyboardShortcut] {
300 match action {
301 ShortcutAction::OpenFile => &self.open_file,
302 ShortcutAction::SwitchFile => &self.switch_file,
303 ShortcutAction::Undo => &self.undo,
304 ShortcutAction::Redo => &self.redo,
305 ShortcutAction::ToggleSidePanel => &self.toggle_side_panel,
306 ShortcutAction::ToggleToolbar => &self.toggle_toolbar,
307 ShortcutAction::GoToEnd => &self.goto_end,
308 ShortcutAction::GoToStart => &self.goto_start,
309 ShortcutAction::SaveStateFile => &self.save_state_file,
310 ShortcutAction::GoToTop => &self.goto_top,
311 ShortcutAction::GoToBottom => &self.goto_bottom,
312 ShortcutAction::ItemFocus => &self.item_focus,
313 ShortcutAction::GroupNew => &self.group_new,
314 ShortcutAction::SelectAll => &self.select_all,
315 ShortcutAction::SelectToggle => &self.select_toggle,
316 ShortcutAction::ReloadWaveform => &self.reload_waveform,
317 ShortcutAction::ZoomIn => &self.zoom_in,
318 ShortcutAction::ZoomOut => &self.zoom_out,
319 ShortcutAction::UiZoomIn => &self.ui_zoom_in,
320 ShortcutAction::UiZoomOut => &self.ui_zoom_out,
321 ShortcutAction::ScrollUp => &self.scroll_up,
322 ShortcutAction::ScrollDown => &self.scroll_down,
323 ShortcutAction::DeleteSelected => &self.delete_selected,
324 ShortcutAction::MarkerAdd => &self.marker_add,
325 ShortcutAction::ToggleMenu => &self.toggle_menu,
326 ShortcutAction::ShowCommandPrompt => &self.show_command_prompt,
327 ShortcutAction::RenameItem => &self.rename_item,
328 ShortcutAction::DividerAdd => &self.divider_add,
329 ShortcutAction::ZoomToFit => &self.zoom_to_fit,
330 ShortcutAction::ZoomToCursor => &self.zoom_to_cursor,
331 ShortcutAction::GoToTime => &self.go_to_time,
332 ShortcutAction::FocusVariableNameFilter => &self.focus_variable_name_filter,
333 }
334 }
335
336 fn execute_action(&self, action: ShortcutAction, msgs: &mut Vec<Message>, state: &SystemState) {
338 match action {
339 ShortcutAction::OpenFile => {
340 msgs.push(Message::OpenFileDialog(crate::file_dialog::OpenMode::Open));
341 }
342 ShortcutAction::SwitchFile => {
343 msgs.push(Message::OpenFileDialog(
344 crate::file_dialog::OpenMode::Switch,
345 ));
346 }
347 ShortcutAction::Redo => {
348 msgs.push(Message::Redo(state.get_count()));
349 }
350 ShortcutAction::Undo => {
351 msgs.push(Message::Undo(state.get_count()));
352 }
353 ShortcutAction::ToggleSidePanel => {
354 msgs.push(Message::SetSidePanelVisible(!state.show_hierarchy()));
355 }
356 ShortcutAction::ToggleToolbar => {
357 msgs.push(Message::SetToolbarVisible(!state.show_toolbar()));
358 }
359 ShortcutAction::GoToEnd => {
360 msgs.push(Message::GoToEnd {
361 viewport_idx: state
362 .user
363 .waves
364 .as_ref()
365 .map_or(0, |waves| waves.last_active_viewport_idx),
366 });
367 }
368 ShortcutAction::GoToStart => {
369 msgs.push(Message::GoToStart {
370 viewport_idx: state
371 .user
372 .waves
373 .as_ref()
374 .map_or(0, |waves| waves.last_active_viewport_idx),
375 });
376 }
377 ShortcutAction::SaveStateFile => {
378 msgs.push(Message::SaveStateFile(state.user.state_file.clone()));
379 }
380 ShortcutAction::GoToTop => {
381 msgs.push(Message::ScrollToItem(0));
382 }
383 ShortcutAction::GoToBottom => {
384 if let Some(waves) = &state.user.waves
385 && waves.displayed_items.len() > 1
386 {
387 msgs.push(Message::ScrollToItem(waves.displayed_items.len() - 1));
388 }
389 }
390 ShortcutAction::GroupNew => {
391 msgs.push(Message::GroupNew {
392 name: None,
393 before: None,
394 items: None,
395 });
396 msgs.push(Message::ShowCommandPrompt("item_rename ".to_owned(), None));
397 }
398 ShortcutAction::ItemFocus => {
399 msgs.push(Message::ShowCommandPrompt("item_focus ".to_string(), None));
400 }
401 ShortcutAction::SelectAll => {
402 msgs.push(Message::ItemSelectAll);
403 }
404 ShortcutAction::SelectToggle => {
405 msgs.push(Message::ToggleItemSelected(None));
406 }
407 ShortcutAction::ReloadWaveform => {
408 msgs.push(Message::ReloadWaveform(
409 state.user.config.behavior.keep_during_reload,
410 ));
411 }
412 ShortcutAction::ZoomIn => {
413 msgs.push(Message::CanvasZoom {
414 mouse_ptr: None,
415 delta: 0.5,
416 viewport_idx: state
417 .user
418 .waves
419 .as_ref()
420 .map_or(0, |waves| waves.last_active_viewport_idx),
421 });
422 }
423 ShortcutAction::ZoomOut => {
424 msgs.push(Message::CanvasZoom {
425 mouse_ptr: None,
426 delta: 2.0,
427 viewport_idx: state
428 .user
429 .waves
430 .as_ref()
431 .map_or(0, |waves| waves.last_active_viewport_idx),
432 });
433 }
434 ShortcutAction::UiZoomIn => {
435 let mut next_factor = f32::INFINITY;
436 for factor in &state.user.config.layout.zoom_factors {
437 if *factor > state.ui_zoom_factor() && *factor < next_factor {
438 next_factor = *factor;
439 }
440 }
441 if next_factor != f32::INFINITY {
442 msgs.push(Message::SetUIZoomFactor(next_factor));
443 }
444 }
445 ShortcutAction::UiZoomOut => {
446 let mut next_factor = 0f32;
447 for factor in &state.user.config.layout.zoom_factors {
448 if *factor < state.ui_zoom_factor() && *factor > next_factor {
449 next_factor = *factor;
450 }
451 }
452 if next_factor > 0f32 {
453 msgs.push(Message::SetUIZoomFactor(next_factor));
454 }
455 }
456 ShortcutAction::ScrollUp => {
457 msgs.push(Message::CanvasScroll {
458 delta: Vec2 {
459 x: 0.,
460 y: -PER_SCROLL_EVENT * SCROLL_EVENTS_PER_PAGE,
461 },
462 viewport_idx: 0,
463 });
464 }
465 ShortcutAction::ScrollDown => {
466 msgs.push(Message::CanvasScroll {
467 delta: Vec2 {
468 x: 0.,
469 y: PER_SCROLL_EVENT * SCROLL_EVENTS_PER_PAGE,
470 },
471 viewport_idx: 0,
472 });
473 }
474 ShortcutAction::DeleteSelected => {
475 msgs.push(Message::RemoveVisibleItems(MessageTarget::CurrentSelection));
476 }
477 ShortcutAction::MarkerAdd => {
478 if let Some(waves) = state.user.waves.as_ref()
479 && let Some(cursor) = waves.cursor.as_ref()
480 {
481 let marker_exists = waves
483 .markers
484 .values()
485 .any(|marker_time| marker_time == cursor);
486 if !marker_exists {
487 msgs.push(Message::AddMarker {
488 time: cursor.clone(),
489 name: None,
490 move_focus: state.user.config.layout.move_focus_on_inserted_marker(),
491 });
492 }
493 }
494 }
495 ShortcutAction::ToggleMenu => {
496 msgs.push(Message::SetMenuVisible(!state.show_menu()));
497 }
498 ShortcutAction::ShowCommandPrompt => {
499 msgs.push(Message::ShowCommandPrompt(String::new(), None));
500 }
501 ShortcutAction::RenameItem => {
502 if let Some(waves) = &state.user.waves
503 && waves.focused_item.is_some()
504 {
505 msgs.push(Message::ShowCommandPrompt("item_rename ".to_owned(), None));
506 }
507 }
508 ShortcutAction::DividerAdd => {
509 msgs.push(Message::AddDivider(None, None));
510 }
511 ShortcutAction::ZoomToFit => {
512 msgs.push(Message::ZoomToFit {
513 viewport_idx: state
514 .user
515 .waves
516 .as_ref()
517 .map_or(0, |waves| waves.last_active_viewport_idx),
518 });
519 }
520 ShortcutAction::ZoomToCursor => {
521 msgs.push(Message::ZoomToCursor {
522 delta: 0.5,
523 viewport_idx: 0,
524 });
525 }
526 ShortcutAction::GoToTime => {
527 msgs.push(Message::SetRequestTextEditFocus(
528 crate::toolbar::TOOLBAR_TIME_ID.to_string(),
529 true,
530 ));
531 }
532 ShortcutAction::FocusVariableNameFilter => {
533 msgs.push(Message::SetRequestTextEditFocus(
534 crate::variable_filter::VARIABLE_FILTER_ID.to_string(),
535 true,
536 ));
537 }
538 }
539 }
540
541 pub fn process(&self, ctx: &egui::Context, msgs: &mut Vec<Message>, state: &SystemState) {
543 for entry in &self.cached_dispatch_table {
545 if self
546 .shortcuts_for_action(entry.action)
547 .iter()
548 .any(|shortcut| ctx.input_mut(|i| i.consume_shortcut(shortcut)))
549 {
550 self.execute_action(entry.action, msgs, state);
551 }
552 }
553 }
554}
555
556fn modifier_priority(shortcuts: &[KeyboardShortcut]) -> u8 {
566 shortcuts
567 .iter()
568 .find_map(|shortcut| {
569 let has_shift = shortcut.modifiers.contains(Modifiers::SHIFT);
570 let has_alt = shortcut.modifiers.contains(Modifiers::ALT);
571
572 match (has_shift, has_alt) {
573 (true, true) => Some(0), (_, true) => Some(1), (true, _) => Some(2), _ => None,
577 }
578 })
579 .unwrap_or(3) }
581
582mod keyboard_shortcuts_serde {
584 use egui::Key;
585 use serde::{Deserializer, Serializer};
586
587 use super::{Deserialize, KeyboardShortcut, Modifiers, Result, Serialize};
588
589 pub fn serialize<S>(shortcuts: &[KeyboardShortcut], serializer: S) -> Result<S::Ok, S::Error>
590 where
591 S: Serializer,
592 {
593 let bindings: Vec<String> = shortcuts
594 .iter()
595 .map(|s| format_binding(s.modifiers, s.logical_key))
596 .collect();
597 bindings.serialize(serializer)
598 }
599
600 pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<KeyboardShortcut>, D::Error>
601 where
602 D: Deserializer<'de>,
603 {
604 let bindings: Vec<String> = Vec::deserialize(deserializer)?;
605 bindings
606 .iter()
607 .map(|s| parse_binding(s).map_err(serde::de::Error::custom))
608 .collect()
609 }
610
611 fn format_binding(modifiers: Modifiers, logical_key: Key) -> String {
612 const MODIFIER_NAMES: &[(Modifiers, &str)] = &[
613 (Modifiers::CTRL, "Ctrl"),
614 (Modifiers::SHIFT, "Shift"),
615 (Modifiers::ALT, "Alt"),
616 (Modifiers::MAC_CMD, "Mac_cmd"),
617 (Modifiers::COMMAND, "Command"),
618 ];
619
620 let mut parts = Vec::with_capacity(6);
622
623 for (modifier, name) in MODIFIER_NAMES {
624 if modifiers.contains(*modifier) {
625 parts.push(*name);
626 }
627 }
628 let key_name = format!("{logical_key:?}");
629 parts.push(&key_name);
630 parts.join("+")
631 }
632
633 fn parse_binding(binding: &str) -> Result<KeyboardShortcut, String> {
634 const MODIFIER_MAP: &[(&str, Modifiers)] = &[
635 ("ctrl", Modifiers::CTRL),
636 ("shift", Modifiers::SHIFT),
637 ("alt", Modifiers::ALT),
638 ("mac_cmd", Modifiers::MAC_CMD),
639 ("command", Modifiers::COMMAND),
640 ("cmd", Modifiers::COMMAND),
641 ];
642
643 let parts: Vec<&str> = binding.split('+').map(str::trim).collect();
644
645 let (modifier_parts, key_str) = match parts.as_slice() {
647 [modifiers @ .., key] => (modifiers, *key),
648 [] => return Err("Empty binding".to_string()),
649 };
650
651 let logical_key =
652 Key::from_name(key_str).ok_or_else(|| format!("Unknown key: {key_str}"))?;
653
654 let modifiers = modifier_parts
656 .iter()
657 .try_fold(Modifiers::NONE, |acc, &modifier_str| {
658 let lower = modifier_str.to_lowercase();
659 MODIFIER_MAP
660 .iter()
661 .find(|(name, _)| name == &lower)
662 .map(|(_, mod_bit)| acc | *mod_bit)
663 .ok_or_else(|| format!("Unknown modifier: {modifier_str}"))
664 })?;
665
666 Ok(KeyboardShortcut::new(modifiers, logical_key))
667 }
668}