Skip to main content

re_ui/
command.rs

1use egui::os::OperatingSystem;
2use egui::{Id, Key, KeyboardShortcut, Modifiers};
3use smallvec::{SmallVec, smallvec};
4
5use crate::context_ext::ContextExt as _;
6
7/// Interface for sending [`UICommand`] messages.
8pub trait UICommandSender {
9    fn send_ui(&self, command: UICommand);
10}
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
13pub struct SetPlaybackSpeed(pub egui::emath::OrderedFloat<f32>);
14
15impl Default for SetPlaybackSpeed {
16    fn default() -> Self {
17        Self(egui::emath::OrderedFloat(1.0))
18    }
19}
20
21/// All the commands we support.
22///
23/// Most are available in the GUI,
24/// some have keyboard shortcuts,
25/// and all are visible in the [`crate::CommandPalette`].
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, strum_macros::EnumIter)]
27pub enum UICommand {
28    // Listed in the order they show up in the command palette by default!
29    Open,
30    OpenUrl,
31    Import,
32
33    /// Save the current recording, or all selected recordings
34    SaveRecording,
35    SaveRecordingSelection,
36    SaveBlueprint,
37    CloseCurrentRecording,
38    CloseAllEntries,
39
40    NextRecording,
41    PreviousRecording,
42
43    NavigateBack,
44    NavigateForward,
45
46    Undo,
47    Redo,
48
49    #[cfg(not(target_arch = "wasm32"))]
50    Quit,
51
52    OpenWebHelp,
53    OpenRerunDiscord,
54
55    ResetViewer,
56    ClearActiveBlueprint,
57    ClearActiveBlueprintAndEnableHeuristics,
58
59    #[cfg(not(target_arch = "wasm32"))]
60    OpenProfiler,
61
62    TogglePanelStateOverrides,
63    ToggleMemoryPanel,
64    ToggleTopPanel,
65    ToggleBlueprintPanel,
66    ExpandBlueprintPanel,
67    ToggleSelectionPanel,
68    ExpandSelectionPanel,
69    ToggleTimePanel,
70    ToggleChunkStoreBrowser,
71    Settings,
72
73    #[cfg(debug_assertions)]
74    ToggleBlueprintInspectionPanel,
75
76    #[cfg(debug_assertions)]
77    ToggleEguiDebugPanel,
78
79    ToggleFullscreen,
80    #[cfg(not(target_arch = "wasm32"))]
81    ZoomIn,
82    #[cfg(not(target_arch = "wasm32"))]
83    ZoomOut,
84    #[cfg(not(target_arch = "wasm32"))]
85    ZoomReset,
86
87    ToggleCommandPalette,
88
89    // Playback:
90    PlaybackTogglePlayPause,
91    PlaybackFollow,
92    PlaybackStepBack,
93    PlaybackStepForward,
94    PlaybackBack,
95    PlaybackForward,
96    PlaybackBackFast,
97    PlaybackForwardFast,
98    PlaybackBeginning,
99    PlaybackEnd,
100    PlaybackRestart,
101    PlaybackSpeed(SetPlaybackSpeed),
102
103    // Dev-tools:
104    #[cfg(not(target_arch = "wasm32"))]
105    ScreenshotWholeApp,
106    #[cfg(not(target_arch = "wasm32"))]
107    PrintChunkStore,
108    #[cfg(not(target_arch = "wasm32"))]
109    PrintBlueprintStore,
110    #[cfg(not(target_arch = "wasm32"))]
111    PrintPrimaryCache,
112
113    #[cfg(debug_assertions)]
114    ResetEguiMemory,
115
116    Share,
117    CopyDirectLink,
118
119    CopyTimeSelectionLink,
120
121    CopyEntityHierarchy,
122
123    // Graphics options:
124    #[cfg(target_arch = "wasm32")]
125    RestartWithWebGl,
126    #[cfg(target_arch = "wasm32")]
127    RestartWithWebGpu,
128
129    // Redap commands
130    AddRedapServer,
131}
132
133impl UICommand {
134    pub fn text(self) -> &'static str {
135        self.text_and_tooltip().0
136    }
137
138    pub fn tooltip(self) -> &'static str {
139        self.text_and_tooltip().1
140    }
141
142    pub fn text_and_tooltip(self) -> (&'static str, &'static str) {
143        match self {
144            Self::SaveRecording => (
145                "Save recording…",
146                "Save all data to a Rerun data file (.rrd)",
147            ),
148
149            Self::SaveRecordingSelection => (
150                "Save current time selection…",
151                "Save data for the current loop selection to a Rerun data file (.rrd)",
152            ),
153
154            Self::SaveBlueprint => (
155                "Save blueprint…",
156                "Save the current viewer setup as a Rerun blueprint file (.rbl)",
157            ),
158
159            Self::Open => (
160                "Open file…",
161                "Open any supported files (.rrd, images, meshes, …) in a new recording",
162            ),
163            Self::OpenUrl => (
164                "Open from URL…",
165                "Open or navigate to data from any supported URL",
166            ),
167            Self::Import => (
168                "Import into current recording…",
169                "Import any supported files (.rrd, images, meshes, …) in the current recording",
170            ),
171
172            Self::CloseCurrentRecording => (
173                "Close current recording",
174                "Close the current recording (unsaved data will be lost)",
175            ),
176
177            Self::CloseAllEntries => (
178                "Close all recordings",
179                "Close all open current recording (unsaved data will be lost)",
180            ),
181
182            Self::NextRecording => ("Next recording", "Switch to the next open recording"),
183            Self::PreviousRecording => (
184                "Previous recording",
185                "Switch to the previous open recording",
186            ),
187
188            Self::NavigateBack => ("Back in history", "Go back in history"),
189            Self::NavigateForward => ("Forward in history", "Go forward in history"),
190
191            Self::Undo => (
192                "Undo",
193                "Undo the last blueprint edit for the open recording",
194            ),
195            Self::Redo => ("Redo", "Redo the last undone thing"),
196
197            #[cfg(not(target_arch = "wasm32"))]
198            Self::Quit => ("Quit", "Close the Rerun Viewer"),
199
200            Self::OpenWebHelp => (
201                "Help",
202                "Visit the help page on our website, with troubleshooting tips and more",
203            ),
204            Self::OpenRerunDiscord => (
205                "Rerun Discord",
206                "Visit the Rerun Discord server, where you can ask questions and get help",
207            ),
208
209            Self::ResetViewer => (
210                "Reset Viewer",
211                "Reset the Viewer to how it looked the first time you ran it, forgetting UI state and all stored blueprints, except the ones loaded from *.rbl resources",
212            ),
213
214            Self::ClearActiveBlueprint => (
215                "Reset to default blueprint",
216                "Clear active blueprint and use the default blueprint instead. If no default blueprint is set, this will use a heuristic blueprint.",
217            ),
218
219            Self::ClearActiveBlueprintAndEnableHeuristics => (
220                "Reset to heuristic blueprint",
221                "Re-populate viewport with automatically chosen views using default visualizers",
222            ),
223
224            #[cfg(not(target_arch = "wasm32"))]
225            Self::OpenProfiler => (
226                "Open profiler",
227                "Starts a profiler, showing what makes the viewer run slow",
228            ),
229
230            Self::ToggleMemoryPanel => (
231                "Toggle memory panel",
232                "View and track current RAM usage inside Rerun Viewer",
233            ),
234
235            Self::TogglePanelStateOverrides => (
236                "Toggle panel state overrides",
237                "Toggle panel state between app blueprint and overrides",
238            ),
239            Self::ToggleTopPanel => ("Toggle top panel", "Toggle the top panel"),
240            Self::ToggleBlueprintPanel => ("Toggle blueprint panel", "Toggle the left panel"),
241            Self::ExpandBlueprintPanel => ("Expand blueprint panel", "Expand the left panel"),
242            Self::ToggleSelectionPanel => ("Toggle selection panel", "Toggle the right panel"),
243            Self::ExpandSelectionPanel => ("Expand selection panel", "Expand the right panel"),
244            Self::ToggleTimePanel => ("Toggle time panel", "Toggle the bottom panel"),
245            Self::ToggleChunkStoreBrowser => (
246                "Toggle chunk store browser",
247                "Toggle the chunk store browser",
248            ),
249            Self::Settings => ("Settings…", "Show the settings screen"),
250
251            #[cfg(debug_assertions)]
252            Self::ToggleBlueprintInspectionPanel => (
253                "Toggle blueprint inspection panel",
254                "Inspect the timeline of the internal blueprint data.",
255            ),
256
257            #[cfg(debug_assertions)]
258            Self::ToggleEguiDebugPanel => (
259                "Toggle egui debug panel",
260                "View and change global egui style settings",
261            ),
262
263            #[cfg(not(target_arch = "wasm32"))]
264            Self::ToggleFullscreen => (
265                "Toggle fullscreen",
266                "Toggle between windowed and fullscreen viewer",
267            ),
268
269            #[cfg(target_arch = "wasm32")]
270            Self::ToggleFullscreen => (
271                "Toggle fullscreen",
272                "Toggle between full viewport dimensions and initial dimensions",
273            ),
274
275            #[cfg(not(target_arch = "wasm32"))]
276            Self::ZoomIn => ("Zoom in", "Increases the UI zoom level"),
277            #[cfg(not(target_arch = "wasm32"))]
278            Self::ZoomOut => ("Zoom out", "Decreases the UI zoom level"),
279            #[cfg(not(target_arch = "wasm32"))]
280            Self::ZoomReset => (
281                "Reset zoom",
282                "Resets the UI zoom level to the operating system's default value",
283            ),
284
285            Self::ToggleCommandPalette => ("Command palette…", "Toggle the Command Palette"),
286
287            Self::PlaybackTogglePlayPause => ("Toggle play/pause", "Either play or pause the time"),
288            Self::PlaybackFollow => ("Follow", "Follow on from end of timeline"),
289            Self::PlaybackStepBack => (
290                "Step backwards",
291                "Move the time marker back to the previous point in time with any data",
292            ),
293            Self::PlaybackStepForward => (
294                "Step forwards",
295                "Move the time marker to the next point in time with any data",
296            ),
297            Self::PlaybackBack => (
298                "Move backwards",
299                "Move the time marker backward by 1 second",
300            ),
301            Self::PlaybackForward => (
302                "Move forwards",
303                "Move the time marker forward by 0.1 seconds",
304            ),
305            Self::PlaybackBackFast => (
306                "Move backwards fast",
307                "Move the time marker backwards by 1 second",
308            ),
309            Self::PlaybackForwardFast => (
310                "Move forwards fast",
311                "Move the time marker forwards by 0.1 seconds",
312            ),
313            Self::PlaybackBeginning => ("Go to beginning", "Go to beginning of timeline"),
314            Self::PlaybackEnd => ("Go to end", "Go to end of timeline"),
315            Self::PlaybackRestart => ("Restart", "Restart from beginning of timeline"),
316
317            Self::PlaybackSpeed(_) => (
318                "Set playback speed",
319                "This is a chord, so you can press 5+0 to set the speed to 50x",
320            ),
321
322            #[cfg(not(target_arch = "wasm32"))]
323            Self::ScreenshotWholeApp => (
324                "Screenshot",
325                "Copy screenshot of the whole app to clipboard",
326            ),
327            #[cfg(not(target_arch = "wasm32"))]
328            Self::PrintChunkStore => (
329                "Print datastore",
330                "Prints the entire chunk store to the console and clipboard. WARNING: this may be A LOT of text.",
331            ),
332            #[cfg(not(target_arch = "wasm32"))]
333            Self::PrintBlueprintStore => (
334                "Print blueprint store",
335                "Prints the entire blueprint store to the console and clipboard. WARNING: this may be A LOT of text.",
336            ),
337            #[cfg(not(target_arch = "wasm32"))]
338            Self::PrintPrimaryCache => (
339                "Print primary cache",
340                "Prints the state of the entire primary cache to the console and clipboard. WARNING: this may be A LOT of text.",
341            ),
342
343            #[cfg(debug_assertions)]
344            Self::ResetEguiMemory => (
345                "Reset egui memory",
346                "Reset egui memory, useful for debugging UI code.",
347            ),
348
349            Self::Share => ("Share…", "Share the current screen as a link"),
350            Self::CopyDirectLink => (
351                "Copy direct link",
352                "Try to copy a shareable link to the current screen. This is not supported for all data sources & viewer states.",
353            ),
354
355            Self::CopyTimeSelectionLink => (
356                "Copy link to selected time range",
357                "Copy a link to the part of the active recording within the loop selection bounds.",
358            ),
359
360            Self::CopyEntityHierarchy => (
361                "Copy entity hierarchy",
362                "Copy the complete entity hierarchy tree of the currently active recording to the clipboard.",
363            ),
364
365            #[cfg(target_arch = "wasm32")]
366            Self::RestartWithWebGl => (
367                "Restart with WebGL",
368                "Reloads the webpage and force WebGL for rendering. All data will be lost.",
369            ),
370            #[cfg(target_arch = "wasm32")]
371            Self::RestartWithWebGpu => (
372                "Restart with WebGPU",
373                "Reloads the webpage and force WebGPU for rendering. All data will be lost.",
374            ),
375
376            Self::AddRedapServer => (
377                "Connect to a server…",
378                "Connect to a Redap server (experimental)",
379            ),
380        }
381    }
382
383    /// All keyboard shortcuts, with the primary first.
384    pub fn kb_shortcuts(self, os: OperatingSystem) -> SmallVec<[KeyboardShortcut; 2]> {
385        fn key(key: Key) -> KeyboardShortcut {
386            KeyboardShortcut::new(Modifiers::NONE, key)
387        }
388
389        fn ctrl(key: Key) -> KeyboardShortcut {
390            KeyboardShortcut::new(Modifiers::CTRL, key)
391        }
392
393        fn cmd(key: Key) -> KeyboardShortcut {
394            KeyboardShortcut::new(Modifiers::COMMAND, key)
395        }
396
397        fn alt(key: Key) -> KeyboardShortcut {
398            KeyboardShortcut::new(Modifiers::ALT, key)
399        }
400
401        fn shift(key: Key) -> KeyboardShortcut {
402            KeyboardShortcut::new(Modifiers::SHIFT, key)
403        }
404
405        fn cmd_shift(key: Key) -> KeyboardShortcut {
406            KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, key)
407        }
408
409        fn cmd_alt(key: Key) -> KeyboardShortcut {
410            KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::ALT, key)
411        }
412
413        fn ctrl_shift(key: Key) -> KeyboardShortcut {
414            KeyboardShortcut::new(Modifiers::CTRL | Modifiers::SHIFT, key)
415        }
416
417        match self {
418            Self::SaveRecording => smallvec![cmd(Key::S)],
419            Self::SaveRecordingSelection => smallvec![cmd_alt(Key::S)],
420            Self::SaveBlueprint => smallvec![],
421            Self::Open => smallvec![cmd(Key::O)],
422            // Some browsers have a "paste and go" action.
423            // But unfortunately there's no standard shortcut for this.
424            // Claude however thinks it's this one (it's not). Let's go with that anyways!
425            Self::OpenUrl => smallvec![cmd_shift(Key::L)],
426            Self::Import => smallvec![cmd_shift(Key::O)],
427            Self::CloseCurrentRecording => smallvec![],
428            Self::CloseAllEntries => smallvec![],
429
430            Self::NextRecording => smallvec![cmd_alt(Key::ArrowDown)],
431            Self::PreviousRecording => smallvec![cmd_alt(Key::ArrowUp)],
432
433            Self::NavigateBack => smallvec![cmd(Key::OpenBracket)],
434            Self::NavigateForward => smallvec![cmd(Key::CloseBracket)],
435
436            Self::Undo => smallvec![cmd(Key::Z)],
437            Self::Redo => {
438                if os == OperatingSystem::Mac {
439                    smallvec![cmd_shift(Key::Z), cmd(Key::Y)]
440                } else {
441                    smallvec![ctrl(Key::Y), ctrl_shift(Key::Z)]
442                }
443            }
444
445            #[cfg(not(target_arch = "wasm32"))]
446            Self::Quit => {
447                if os == OperatingSystem::Windows {
448                    smallvec![KeyboardShortcut::new(Modifiers::ALT, Key::F4)]
449                } else {
450                    smallvec![cmd(Key::Q)]
451                }
452            }
453
454            Self::OpenWebHelp => smallvec![],
455            Self::OpenRerunDiscord => smallvec![],
456
457            Self::ResetViewer => smallvec![ctrl_shift(Key::R)],
458            Self::ClearActiveBlueprint => smallvec![],
459            Self::ClearActiveBlueprintAndEnableHeuristics => smallvec![],
460
461            #[cfg(not(target_arch = "wasm32"))]
462            Self::OpenProfiler => smallvec![ctrl_shift(Key::P)],
463            Self::ToggleMemoryPanel => smallvec![ctrl_shift(Key::M)],
464            Self::TogglePanelStateOverrides => smallvec![],
465            Self::ToggleTopPanel => smallvec![],
466            Self::ToggleBlueprintPanel => smallvec![ctrl_shift(Key::B)],
467            Self::ExpandBlueprintPanel => smallvec![],
468            Self::ToggleSelectionPanel => smallvec![ctrl_shift(Key::S)],
469            Self::ExpandSelectionPanel => smallvec![],
470            Self::ToggleTimePanel => smallvec![ctrl_shift(Key::T)],
471            Self::ToggleChunkStoreBrowser => smallvec![ctrl_shift(Key::D)],
472            Self::Settings => smallvec![cmd(Key::Comma)],
473
474            #[cfg(debug_assertions)]
475            Self::ToggleBlueprintInspectionPanel => smallvec![ctrl_shift(Key::I)],
476
477            #[cfg(debug_assertions)]
478            Self::ToggleEguiDebugPanel => smallvec![ctrl_shift(Key::U)],
479
480            Self::ToggleFullscreen => {
481                if cfg!(target_arch = "wasm32") {
482                    smallvec![]
483                } else {
484                    smallvec![key(Key::F11)]
485                }
486            }
487
488            #[cfg(not(target_arch = "wasm32"))]
489            Self::ZoomIn => smallvec![egui::gui_zoom::kb_shortcuts::ZOOM_IN],
490            #[cfg(not(target_arch = "wasm32"))]
491            Self::ZoomOut => smallvec![egui::gui_zoom::kb_shortcuts::ZOOM_OUT],
492            #[cfg(not(target_arch = "wasm32"))]
493            Self::ZoomReset => smallvec![egui::gui_zoom::kb_shortcuts::ZOOM_RESET],
494
495            Self::ToggleCommandPalette => smallvec![cmd(Key::P), cmd(Key::K)],
496
497            Self::PlaybackTogglePlayPause => smallvec![key(Key::Space)],
498            Self::PlaybackFollow => smallvec![alt(Key::ArrowRight)],
499            Self::PlaybackStepBack => smallvec![cmd(Key::ArrowLeft)],
500            Self::PlaybackStepForward => smallvec![cmd(Key::ArrowRight)],
501            Self::PlaybackBack => smallvec![key(Key::ArrowLeft)],
502            Self::PlaybackForward => smallvec![key(Key::ArrowRight)],
503            Self::PlaybackBackFast => smallvec![shift(Key::ArrowLeft)],
504            Self::PlaybackForwardFast => smallvec![shift(Key::ArrowRight)],
505            Self::PlaybackBeginning => smallvec![key(Key::Home)],
506            Self::PlaybackEnd => smallvec![key(Key::End)],
507            Self::PlaybackRestart => smallvec![alt(Key::ArrowLeft)],
508
509            Self::PlaybackSpeed(_) => {
510                // This is a chord, so no single shortcut.
511                smallvec![]
512            }
513
514            #[cfg(not(target_arch = "wasm32"))]
515            Self::ScreenshotWholeApp => smallvec![],
516            #[cfg(not(target_arch = "wasm32"))]
517            Self::PrintChunkStore => smallvec![],
518            #[cfg(not(target_arch = "wasm32"))]
519            Self::PrintBlueprintStore => smallvec![],
520            #[cfg(not(target_arch = "wasm32"))]
521            Self::PrintPrimaryCache => smallvec![],
522
523            #[cfg(debug_assertions)]
524            Self::ResetEguiMemory => smallvec![],
525
526            Self::Share => smallvec![cmd(Key::L)],
527            Self::CopyDirectLink => smallvec![],
528
529            Self::CopyTimeSelectionLink => smallvec![],
530
531            Self::CopyEntityHierarchy => smallvec![ctrl_shift(Key::E)],
532
533            #[cfg(target_arch = "wasm32")]
534            Self::RestartWithWebGl => smallvec![],
535            #[cfg(target_arch = "wasm32")]
536            Self::RestartWithWebGpu => smallvec![],
537
538            Self::AddRedapServer => smallvec![],
539        }
540    }
541
542    /// Primary keyboard shortcut
543    pub fn primary_kb_shortcut(self, os: OperatingSystem) -> Option<KeyboardShortcut> {
544        self.kb_shortcuts(os).first().copied()
545    }
546
547    /// Return the keyboard shortcut for this command, nicely formatted
548    // TODO(emilk): use Help/IconText instead
549    pub fn formatted_kb_shortcut(self, egui_ctx: &egui::Context) -> Option<String> {
550        if matches!(self, Self::PlaybackSpeed(_)) {
551            return Some("01-99".to_owned());
552        }
553        // Note: we only show the primary shortcut to the user.
554        // The fallbacks are there for people who have muscle memory for the other shortcuts.
555        self.primary_kb_shortcut(egui_ctx.os())
556            .map(|shortcut| egui_ctx.format_shortcut(&shortcut))
557    }
558
559    pub fn icon(self) -> Option<&'static crate::Icon> {
560        match self {
561            Self::OpenWebHelp => Some(&crate::icons::EXTERNAL_LINK),
562            Self::OpenRerunDiscord => Some(&crate::icons::DISCORD),
563            _ => None,
564        }
565    }
566
567    pub fn is_link(self) -> bool {
568        matches!(self, Self::OpenWebHelp | Self::OpenRerunDiscord)
569    }
570
571    fn handle_playback_chord(ctx: &egui::Context) -> Option<Self> {
572        const CHORD_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500);
573        const NUMBER_KEYS: [Key; 10] = [
574            Key::Num0,
575            Key::Num1,
576            Key::Num2,
577            Key::Num3,
578            Key::Num4,
579            Key::Num5,
580            Key::Num6,
581            Key::Num7,
582            Key::Num8,
583            Key::Num9,
584        ];
585
586        fn key_to_digit(key: Key) -> Option<char> {
587            let i = NUMBER_KEYS.iter().position(|&k| k == key)?;
588            char::from_digit(i as u32, 10)
589        }
590
591        #[derive(Default, Clone)]
592        struct PlaybackChordState {
593            last_key_time: Option<web_time::Instant>,
594            accumulated: String,
595        }
596
597        if ctx.text_edit_focused() {
598            return None;
599        }
600
601        let mut chord_state = ctx.data_mut(|data| {
602            data.get_temp_mut_or_default::<PlaybackChordState>(Id::NULL)
603                .clone()
604        });
605
606        let now = web_time::Instant::now();
607
608        let pressed_number = ctx.input(|i| {
609            let mut pressed_number = NUMBER_KEYS.iter().find(|&&k| i.key_pressed(k)).copied();
610            let has_other = i.keys_down.iter().any(|k| !NUMBER_KEYS.contains(k));
611
612            if has_other || i.modifiers.any() {
613                chord_state = PlaybackChordState::default();
614                pressed_number = None;
615            }
616
617            pressed_number
618        });
619
620        // Check if timeout expired - clear old state
621        if let Some(last_time) = chord_state.last_key_time
622            && now.duration_since(last_time) >= CHORD_TIMEOUT
623        {
624            chord_state = PlaybackChordState::default();
625        }
626
627        let mut command = None;
628
629        // Handle number key press
630        if let Some(key) = pressed_number {
631            if let Some(digit) = key_to_digit(key) {
632                chord_state.accumulated.push(digit);
633            }
634
635            chord_state.last_key_time = Some(now);
636
637            // Leading zeros should divide the speed by 10 for each zero.
638            // So e.g. 05 = 0.5x speed, 005 = 0.05x speed, etc.
639            let leading_zeros = chord_state
640                .accumulated
641                .chars()
642                .take_while(|&c| c == '0')
643                .count();
644
645            let factor = 10usize.pow(leading_zeros as u32);
646
647            if let Ok(speed) = chord_state.accumulated.parse::<f32>()
648                && speed > 0.0
649            {
650                command = Some(Self::PlaybackSpeed(SetPlaybackSpeed(
651                    egui::emath::OrderedFloat(speed / factor as f32),
652                )));
653            }
654        }
655
656        ctx.data_mut(|data| data.insert_temp(Id::NULL, chord_state.clone()));
657
658        command
659    }
660
661    #[must_use = "Returns the Command that was triggered by some keyboard shortcut"]
662    pub fn listen_for_kb_shortcut(egui_ctx: &egui::Context) -> Option<Self> {
663        fn conflicts_with_text_editing(kb_shortcut: &KeyboardShortcut) -> bool {
664            // TODO(emilk): move this into egui
665            kb_shortcut.modifiers.is_none()
666                || matches!(
667                    kb_shortcut.logical_key,
668                    Key::Space
669                        | Key::ArrowLeft
670                        | Key::ArrowRight
671                        | Key::ArrowUp
672                        | Key::ArrowDown
673                        | Key::Home
674                        | Key::End
675                )
676        }
677
678        use strum::IntoEnumIterator as _;
679
680        let text_edit_has_focus = egui_ctx.text_edit_focused();
681
682        let mut commands: Vec<(KeyboardShortcut, Self)> = Self::iter()
683            .flat_map(|cmd| {
684                cmd.kb_shortcuts(egui_ctx.os())
685                    .into_iter()
686                    .map(move |kb_shortcut| (kb_shortcut, cmd))
687            })
688            .collect();
689
690        // If the user pressed `Cmd-Shift-S` then egui will match that
691        // with both `Cmd-Shift-S` and `Cmd-S`.
692        // The reason is that `Shift` (and `Alt`) are sometimes required to produce certain keys,
693        // such as `+` (`Shift =` on an american keyboard).
694        // The result of this is that we must check for `Cmd-Shift-S` before `Cmd-S`, etc.
695        // So we order the commands here so that the commands with `Shift` and `Alt` in them
696        // are checked first.
697        commands.sort_by_key(|(kb_shortcut, _cmd)| {
698            let num_shift_alts =
699                kb_shortcut.modifiers.shift as i32 + kb_shortcut.modifiers.alt as i32;
700            -num_shift_alts // most first
701        });
702
703        let command = egui_ctx.input_mut(|input| {
704            for (kb_shortcut, command) in commands {
705                if text_edit_has_focus && conflicts_with_text_editing(&kb_shortcut) {
706                    continue; // Make sure we can move text cursor with alt-arrow keys, etc
707                }
708
709                if input.consume_shortcut(&kb_shortcut) {
710                    // Clear the shortcut key from input to prevent it from propagating to other UI component.
711                    input.keys_down.remove(&kb_shortcut.logical_key);
712                    return Some(command);
713                }
714            }
715            None
716        });
717
718        if command.is_none() {
719            Self::handle_playback_chord(egui_ctx)
720        } else {
721            command
722        }
723    }
724
725    /// Show this command as a menu-button.
726    ///
727    /// If clicked, enqueue the command.
728    pub fn menu_button_ui(
729        self,
730        ui: &mut egui::Ui,
731        command_sender: &impl UICommandSender,
732    ) -> egui::Response {
733        let button = self.menu_button(ui.ctx());
734        let mut response = ui.add(button).on_hover_text(self.tooltip());
735
736        if self.is_link() {
737            response = response.on_hover_cursor(egui::CursorIcon::PointingHand);
738        }
739
740        if response.clicked() {
741            command_sender.send_ui(self);
742            ui.close();
743        }
744
745        response
746    }
747
748    pub fn menu_button(self, egui_ctx: &egui::Context) -> egui::Button<'static> {
749        let tokens = egui_ctx.tokens();
750
751        let mut button = if let Some(icon) = self.icon() {
752            egui::Button::image_and_text(
753                icon.as_image()
754                    .tint(tokens.label_button_icon_color)
755                    .fit_to_exact_size(tokens.small_icon_size),
756                self.text(),
757            )
758        } else {
759            egui::Button::new(self.text())
760        };
761
762        if let Some(shortcut_text) = self.formatted_kb_shortcut(egui_ctx) {
763            button = button.shortcut_text(shortcut_text);
764        }
765
766        button
767    }
768
769    /// Show name of command and how to activate it
770    pub fn tooltip_ui(self, ui: &mut egui::Ui) {
771        let os = ui.os();
772
773        let (label, details) = self.text_and_tooltip();
774
775        if let Some(shortcut) = self.primary_kb_shortcut(os) {
776            crate::Help::new_without_title()
777                .control(label, crate::IconText::from_keyboard_shortcut(os, shortcut))
778                .ui(ui);
779        } else {
780            ui.label(label);
781        }
782
783        ui.set_max_width(220.0);
784        ui.label(details);
785    }
786}
787
788#[test]
789fn check_for_clashing_command_shortcuts() {
790    fn clashes(a: KeyboardShortcut, b: KeyboardShortcut) -> bool {
791        if a.logical_key != b.logical_key {
792            return false;
793        }
794
795        if a.modifiers.alt != b.modifiers.alt {
796            return false;
797        }
798
799        if a.modifiers.shift != b.modifiers.shift {
800            return false;
801        }
802
803        // On Non-Mac, command is interpreted as ctrl!
804        (a.modifiers.command || a.modifiers.ctrl) == (b.modifiers.command || b.modifiers.ctrl)
805    }
806
807    use strum::IntoEnumIterator as _;
808
809    for os in [
810        OperatingSystem::Mac,
811        OperatingSystem::Windows,
812        OperatingSystem::Nix,
813    ] {
814        for a_cmd in UICommand::iter() {
815            for a_shortcut in a_cmd.kb_shortcuts(os) {
816                for b_cmd in UICommand::iter() {
817                    if a_cmd == b_cmd {
818                        continue;
819                    }
820                    for b_shortcut in b_cmd.kb_shortcuts(os) {
821                        assert!(
822                            !clashes(a_shortcut, b_shortcut),
823                            "Command '{a_cmd:?}' and '{b_cmd:?}' have overlapping keyboard shortcuts: {:?} vs {:?}",
824                            a_shortcut.format(&egui::ModifierNames::NAMES, true),
825                            b_shortcut.format(&egui::ModifierNames::NAMES, true),
826                        );
827                    }
828                }
829            }
830        }
831    }
832}