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