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