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#[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#[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 let mut dispatch_table = Vec::with_capacity(10);
145
146 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 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 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 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), (_, true) => Some(1), (true, _) => Some(2), _ => None,
499 }
500 })
501 .unwrap_or(3) }
503
504mod 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 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 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 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}