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