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