Skip to main content

libsurfer/
keyboard_shortcuts.rs

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