Skip to main content

libsurfer/
keyboard_shortcuts.rs

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; // Update to reflect the actual number of shortcut actions defined in ShortcutAction enum
11
12// Table-driven dispatch action enum
13#[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// Cached dispatch table entry: (action, modifier_priority)
50#[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        // Determine if the target OS is macOS for formatting purposes.
140        // Skip macOS formatting for tests.
141        #[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        // Pre-allocate with known capacity and build entries
154        let mut dispatch_table = Vec::with_capacity(NUMBER_OF_SHORTCUTS);
155
156        // Create entry for each action with its priority
157        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        // Sort by modifier priority (lower number = higher priority)
294        dispatch_table.sort_by_key(|entry| entry.priority);
295        dispatch_table
296    }
297
298    /// Get the keyboard shortcuts for a given action.
299    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    /// Execute the action corresponding to the given shortcut.
337    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                    // Check if a marker already exists at the cursor position
482                    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    /// Process the keyboard shortcuts and execute the corresponding actions.
542    pub fn process(&self, ctx: &egui::Context, msgs: &mut Vec<Message>, state: &SystemState) {
543        // Execute actions matching pressed shortcuts using cached dispatch table
544        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
556/// Determine the priority of the keyboard shortcuts based on their modifiers.
557///
558/// This is because egui's shortcut handling ignores modifiers to some extent,
559/// so they must be checked in order of priority.
560///
561/// The egui design choice makes sense, since depending on keyboard you may not
562/// be able to press certain keys without modifiers, so one would like those
563/// symbols to match independent of modifiers. However, that leads to that the
564/// more modifiers, the earlier the short cut must be checked.
565fn 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), // Shift+Alt highest priority
574                (_, true) => Some(1),    // Alt second priority
575                (true, _) => Some(2),    // Shift third priority
576                _ => None,
577            }
578        })
579        .unwrap_or(3) // Rest lowest priority
580}
581
582// Custom serialization/deserialization for Vec<KeyboardShortcut>
583mod 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        // Pre-allocate with capacity for max 6 items (5 modifiers + key)
621        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        // Use slice pattern to extract key and modifiers
646        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        // Use fold to accumulate modifiers
655        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}