re_ui/
command.rs

1use egui::{Key, KeyboardShortcut, Modifiers, os::OperatingSystem};
2use smallvec::{SmallVec, smallvec};
3
4use crate::context_ext::ContextExt as _;
5
6/// Interface for sending [`UICommand`] messages.
7pub trait UICommandSender {
8    fn send_ui(&self, command: UICommand);
9}
10
11/// All the commands we support.
12///
13/// Most are available in the GUI,
14/// some have keyboard shortcuts,
15/// and all are visible in the [`crate::CommandPalette`].
16#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, strum_macros::EnumIter)]
17pub enum UICommand {
18    // Listed in the order they show up in the command palette by default!
19    Open,
20    OpenUrl,
21    Import,
22
23    /// Save the current recording, or all selected recordings
24    SaveRecording,
25    SaveRecordingSelection,
26    SaveBlueprint,
27    CloseCurrentRecording,
28    CloseAllEntries,
29
30    Undo,
31    Redo,
32
33    #[cfg(not(target_arch = "wasm32"))]
34    Quit,
35
36    OpenWebHelp,
37    OpenRerunDiscord,
38
39    ResetViewer,
40    ClearActiveBlueprint,
41    ClearActiveBlueprintAndEnableHeuristics,
42
43    #[cfg(not(target_arch = "wasm32"))]
44    OpenProfiler,
45
46    TogglePanelStateOverrides,
47    ToggleMemoryPanel,
48    ToggleTopPanel,
49    ToggleBlueprintPanel,
50    ExpandBlueprintPanel,
51    ToggleSelectionPanel,
52    ToggleTimePanel,
53    ToggleChunkStoreBrowser,
54    Settings,
55
56    #[cfg(debug_assertions)]
57    ToggleBlueprintInspectionPanel,
58
59    #[cfg(debug_assertions)]
60    ToggleEguiDebugPanel,
61
62    ToggleFullscreen,
63    #[cfg(not(target_arch = "wasm32"))]
64    ZoomIn,
65    #[cfg(not(target_arch = "wasm32"))]
66    ZoomOut,
67    #[cfg(not(target_arch = "wasm32"))]
68    ZoomReset,
69
70    ToggleCommandPalette,
71
72    // Playback:
73    PlaybackTogglePlayPause,
74    PlaybackFollow,
75    PlaybackStepBack,
76    PlaybackStepForward,
77    PlaybackRestart,
78
79    // Dev-tools:
80    #[cfg(not(target_arch = "wasm32"))]
81    ScreenshotWholeApp,
82    #[cfg(not(target_arch = "wasm32"))]
83    PrintChunkStore,
84    #[cfg(not(target_arch = "wasm32"))]
85    PrintBlueprintStore,
86    #[cfg(not(target_arch = "wasm32"))]
87    PrintPrimaryCache,
88
89    #[cfg(debug_assertions)]
90    ResetEguiMemory,
91
92    Share,
93    CopyDirectLink,
94
95    CopyTimeRangeLink,
96
97    CopyEntityHierarchy,
98
99    // Graphics options:
100    #[cfg(target_arch = "wasm32")]
101    RestartWithWebGl,
102    #[cfg(target_arch = "wasm32")]
103    RestartWithWebGpu,
104
105    // Redap commands
106    AddRedapServer,
107}
108
109impl UICommand {
110    pub fn text(self) -> &'static str {
111        self.text_and_tooltip().0
112    }
113
114    pub fn tooltip(self) -> &'static str {
115        self.text_and_tooltip().1
116    }
117
118    pub fn text_and_tooltip(self) -> (&'static str, &'static str) {
119        match self {
120            Self::SaveRecording => (
121                "Save recording…",
122                "Save all data to a Rerun data file (.rrd)",
123            ),
124
125            Self::SaveRecordingSelection => (
126                "Save current time selection…",
127                "Save data for the current loop selection to a Rerun data file (.rrd)",
128            ),
129
130            Self::SaveBlueprint => (
131                "Save blueprint…",
132                "Save the current viewer setup as a Rerun blueprint file (.rbl)",
133            ),
134
135            Self::Open => (
136                "Open…",
137                "Open any supported files (.rrd, images, meshes, …) in a new recording",
138            ),
139            Self::OpenUrl => (
140                "Open from URL…",
141                "Open or navigate to data from any supported URL",
142            ),
143            Self::Import => (
144                "Import into current recording…",
145                "Import any supported files (.rrd, images, meshes, …) in the current recording",
146            ),
147
148            Self::CloseCurrentRecording => (
149                "Close current recording",
150                "Close the current recording (unsaved data will be lost)",
151            ),
152
153            Self::CloseAllEntries => (
154                "Close all recordings",
155                "Close all open current recording (unsaved data will be lost)",
156            ),
157
158            Self::Undo => (
159                "Undo",
160                "Undo the last blueprint edit for the open recording",
161            ),
162            Self::Redo => ("Redo", "Redo the last undone thing"),
163
164            #[cfg(not(target_arch = "wasm32"))]
165            Self::Quit => ("Quit", "Close the Rerun Viewer"),
166
167            Self::OpenWebHelp => (
168                "Help",
169                "Visit the help page on our website, with troubleshooting tips and more",
170            ),
171            Self::OpenRerunDiscord => (
172                "Rerun Discord",
173                "Visit the Rerun Discord server, where you can ask questions and get help",
174            ),
175
176            Self::ResetViewer => (
177                "Reset Viewer",
178                "Reset the Viewer to how it looked the first time you ran it, forgetting all stored blueprints and UI state",
179            ),
180
181            Self::ClearActiveBlueprint => (
182                "Reset to default blueprint",
183                "Clear active blueprint and use the default blueprint instead. If no default blueprint is set, this will use a heuristic blueprint.",
184            ),
185
186            Self::ClearActiveBlueprintAndEnableHeuristics => (
187                "Reset to heuristic blueprint",
188                "Re-populate viewport with automatically chosen views using default visualizers",
189            ),
190
191            #[cfg(not(target_arch = "wasm32"))]
192            Self::OpenProfiler => (
193                "Open profiler",
194                "Starts a profiler, showing what makes the viewer run slow",
195            ),
196
197            Self::ToggleMemoryPanel => (
198                "Toggle memory panel",
199                "View and track current RAM usage inside Rerun Viewer",
200            ),
201
202            Self::TogglePanelStateOverrides => (
203                "Toggle panel state overrides",
204                "Toggle panel state between app blueprint and overrides",
205            ),
206            Self::ToggleTopPanel => ("Toggle top panel", "Toggle the top panel"),
207            Self::ToggleBlueprintPanel => ("Toggle blueprint panel", "Toggle the left panel"),
208            Self::ExpandBlueprintPanel => ("Expand blueprint panel", "Expand the left panel"),
209            Self::ToggleSelectionPanel => ("Toggle selection panel", "Toggle the right panel"),
210            Self::ToggleTimePanel => ("Toggle time panel", "Toggle the bottom panel"),
211            Self::ToggleChunkStoreBrowser => (
212                "Toggle chunk store browser",
213                "Toggle the chunk store browser",
214            ),
215            Self::Settings => ("Settings…", "Show the settings screen"),
216
217            #[cfg(debug_assertions)]
218            Self::ToggleBlueprintInspectionPanel => (
219                "Toggle blueprint inspection panel",
220                "Inspect the timeline of the internal blueprint data.",
221            ),
222
223            #[cfg(debug_assertions)]
224            Self::ToggleEguiDebugPanel => (
225                "Toggle egui debug panel",
226                "View and change global egui style settings",
227            ),
228
229            #[cfg(not(target_arch = "wasm32"))]
230            Self::ToggleFullscreen => (
231                "Toggle fullscreen",
232                "Toggle between windowed and fullscreen viewer",
233            ),
234
235            #[cfg(target_arch = "wasm32")]
236            Self::ToggleFullscreen => (
237                "Toggle fullscreen",
238                "Toggle between full viewport dimensions and initial dimensions",
239            ),
240
241            #[cfg(not(target_arch = "wasm32"))]
242            Self::ZoomIn => ("Zoom in", "Increases the UI zoom level"),
243            #[cfg(not(target_arch = "wasm32"))]
244            Self::ZoomOut => ("Zoom out", "Decreases the UI zoom level"),
245            #[cfg(not(target_arch = "wasm32"))]
246            Self::ZoomReset => (
247                "Reset zoom",
248                "Resets the UI zoom level to the operating system's default value",
249            ),
250
251            Self::ToggleCommandPalette => ("Command palette…", "Toggle the Command Palette"),
252
253            Self::PlaybackTogglePlayPause => ("Toggle play/pause", "Either play or pause the time"),
254            Self::PlaybackFollow => ("Follow", "Follow on from end of timeline"),
255            Self::PlaybackStepBack => (
256                "Step backwards",
257                "Move the time marker back to the previous point in time with any data",
258            ),
259            Self::PlaybackStepForward => (
260                "Step forwards",
261                "Move the time marker to the next point in time with any data",
262            ),
263            Self::PlaybackRestart => ("Restart", "Restart from beginning of timeline"),
264
265            #[cfg(not(target_arch = "wasm32"))]
266            Self::ScreenshotWholeApp => (
267                "Screenshot",
268                "Copy screenshot of the whole app to clipboard",
269            ),
270            #[cfg(not(target_arch = "wasm32"))]
271            Self::PrintChunkStore => (
272                "Print datastore",
273                "Prints the entire chunk store to the console and clipboard. WARNING: this may be A LOT of text.",
274            ),
275            #[cfg(not(target_arch = "wasm32"))]
276            Self::PrintBlueprintStore => (
277                "Print blueprint store",
278                "Prints the entire blueprint store to the console and clipboard. WARNING: this may be A LOT of text.",
279            ),
280            #[cfg(not(target_arch = "wasm32"))]
281            Self::PrintPrimaryCache => (
282                "Print primary cache",
283                "Prints the state of the entire primary cache to the console and clipboard. WARNING: this may be A LOT of text.",
284            ),
285
286            #[cfg(debug_assertions)]
287            Self::ResetEguiMemory => (
288                "Reset egui memory",
289                "Reset egui memory, useful for debugging UI code.",
290            ),
291
292            Self::Share => ("Share…", "Share the current screen as a link"),
293            Self::CopyDirectLink => (
294                "Copy direct link",
295                "Try to copy a shareable link to the current screen. This is not supported for all data sources & viewer states.",
296            ),
297
298            Self::CopyTimeRangeLink => (
299                "Copy link to selected time range",
300                "Copy a link to the part of the active recording within the loop selection bounds.",
301            ),
302
303            Self::CopyEntityHierarchy => (
304                "Copy entity hierarchy",
305                "Copy the complete entity hierarchy tree of the currently active recording to the clipboard.",
306            ),
307
308            #[cfg(target_arch = "wasm32")]
309            Self::RestartWithWebGl => (
310                "Restart with WebGL",
311                "Reloads the webpage and force WebGL for rendering. All data will be lost.",
312            ),
313            #[cfg(target_arch = "wasm32")]
314            Self::RestartWithWebGpu => (
315                "Restart with WebGPU",
316                "Reloads the webpage and force WebGPU for rendering. All data will be lost.",
317            ),
318
319            Self::AddRedapServer => (
320                "Add Redap server…",
321                "Connect to a Redap server (experimental)",
322            ),
323        }
324    }
325
326    /// All keyboard shortcuts, with the primary first.
327    pub fn kb_shortcuts(self, os: OperatingSystem) -> SmallVec<[KeyboardShortcut; 2]> {
328        fn key(key: Key) -> KeyboardShortcut {
329            KeyboardShortcut::new(Modifiers::NONE, key)
330        }
331
332        fn ctrl(key: Key) -> KeyboardShortcut {
333            KeyboardShortcut::new(Modifiers::CTRL, key)
334        }
335
336        fn cmd(key: Key) -> KeyboardShortcut {
337            KeyboardShortcut::new(Modifiers::COMMAND, key)
338        }
339
340        fn alt(key: Key) -> KeyboardShortcut {
341            KeyboardShortcut::new(Modifiers::ALT, key)
342        }
343
344        fn cmd_shift(key: Key) -> KeyboardShortcut {
345            KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, key)
346        }
347
348        fn cmd_alt(key: Key) -> KeyboardShortcut {
349            KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::ALT, key)
350        }
351
352        fn ctrl_shift(key: Key) -> KeyboardShortcut {
353            KeyboardShortcut::new(Modifiers::CTRL | Modifiers::SHIFT, key)
354        }
355
356        match self {
357            Self::SaveRecording => smallvec![cmd(Key::S)],
358            Self::SaveRecordingSelection => smallvec![cmd_alt(Key::S)],
359            Self::SaveBlueprint => smallvec![],
360            Self::Open => smallvec![cmd(Key::O)],
361            // Some browsers have a "paste and go" action.
362            // But unfortunately there's no standard shortcut for this.
363            // Claude however thinks it's this one (it's not). Let's go with that anyways!
364            Self::OpenUrl => smallvec![cmd_shift(Key::L)],
365            Self::Import => smallvec![cmd_shift(Key::O)],
366            Self::CloseCurrentRecording => smallvec![],
367            Self::CloseAllEntries => smallvec![],
368
369            Self::Undo => smallvec![cmd(Key::Z)],
370            Self::Redo => {
371                if os == OperatingSystem::Mac {
372                    smallvec![cmd_shift(Key::Z), cmd(Key::Y)]
373                } else {
374                    smallvec![ctrl(Key::Y), ctrl_shift(Key::Z)]
375                }
376            }
377
378            #[cfg(not(target_arch = "wasm32"))]
379            Self::Quit => {
380                if os == OperatingSystem::Windows {
381                    smallvec![KeyboardShortcut::new(Modifiers::ALT, Key::F4)]
382                } else {
383                    smallvec![cmd(Key::Q)]
384                }
385            }
386
387            Self::OpenWebHelp => smallvec![],
388            Self::OpenRerunDiscord => smallvec![],
389
390            Self::ResetViewer => smallvec![ctrl_shift(Key::R)],
391            Self::ClearActiveBlueprint => smallvec![],
392            Self::ClearActiveBlueprintAndEnableHeuristics => smallvec![],
393
394            #[cfg(not(target_arch = "wasm32"))]
395            Self::OpenProfiler => smallvec![ctrl_shift(Key::P)],
396            Self::ToggleMemoryPanel => smallvec![ctrl_shift(Key::M)],
397            Self::TogglePanelStateOverrides => smallvec![],
398            Self::ToggleTopPanel => smallvec![],
399            Self::ToggleBlueprintPanel => smallvec![ctrl_shift(Key::B)],
400            Self::ExpandBlueprintPanel => smallvec![],
401            Self::ToggleSelectionPanel => smallvec![ctrl_shift(Key::S)],
402            Self::ToggleTimePanel => smallvec![ctrl_shift(Key::T)],
403            Self::ToggleChunkStoreBrowser => smallvec![ctrl_shift(Key::D)],
404            Self::Settings => smallvec![cmd(Key::Comma)],
405
406            #[cfg(debug_assertions)]
407            Self::ToggleBlueprintInspectionPanel => smallvec![ctrl_shift(Key::I)],
408
409            #[cfg(debug_assertions)]
410            Self::ToggleEguiDebugPanel => smallvec![ctrl_shift(Key::U)],
411
412            Self::ToggleFullscreen => {
413                if cfg!(target_arch = "wasm32") {
414                    smallvec![]
415                } else {
416                    smallvec![key(Key::F11)]
417                }
418            }
419
420            #[cfg(not(target_arch = "wasm32"))]
421            Self::ZoomIn => smallvec![egui::gui_zoom::kb_shortcuts::ZOOM_IN],
422            #[cfg(not(target_arch = "wasm32"))]
423            Self::ZoomOut => smallvec![egui::gui_zoom::kb_shortcuts::ZOOM_OUT],
424            #[cfg(not(target_arch = "wasm32"))]
425            Self::ZoomReset => smallvec![egui::gui_zoom::kb_shortcuts::ZOOM_RESET],
426
427            Self::ToggleCommandPalette => smallvec![cmd(Key::P)],
428
429            Self::PlaybackTogglePlayPause => smallvec![key(Key::Space)],
430            Self::PlaybackFollow => smallvec![alt(Key::ArrowRight)],
431            Self::PlaybackStepBack => smallvec![cmd(Key::ArrowLeft)],
432            Self::PlaybackStepForward => smallvec![cmd(Key::ArrowRight)],
433            Self::PlaybackRestart => smallvec![alt(Key::ArrowLeft)],
434
435            #[cfg(not(target_arch = "wasm32"))]
436            Self::ScreenshotWholeApp => smallvec![],
437            #[cfg(not(target_arch = "wasm32"))]
438            Self::PrintChunkStore => smallvec![],
439            #[cfg(not(target_arch = "wasm32"))]
440            Self::PrintBlueprintStore => smallvec![],
441            #[cfg(not(target_arch = "wasm32"))]
442            Self::PrintPrimaryCache => smallvec![],
443
444            #[cfg(debug_assertions)]
445            Self::ResetEguiMemory => smallvec![],
446
447            Self::Share => smallvec![cmd(Key::L)],
448            Self::CopyDirectLink => smallvec![],
449
450            Self::CopyTimeRangeLink => smallvec![],
451
452            Self::CopyEntityHierarchy => smallvec![ctrl_shift(Key::E)],
453
454            #[cfg(target_arch = "wasm32")]
455            Self::RestartWithWebGl => smallvec![],
456            #[cfg(target_arch = "wasm32")]
457            Self::RestartWithWebGpu => smallvec![],
458
459            Self::AddRedapServer => smallvec![],
460        }
461    }
462
463    /// Primary keyboard shortcut
464    pub fn primary_kb_shortcut(self, os: OperatingSystem) -> Option<KeyboardShortcut> {
465        self.kb_shortcuts(os).first().copied()
466    }
467
468    /// Return the keyboard shortcut for this command, nicely formatted
469    // TODO(emilk): use Help/IconText instead
470    pub fn formatted_kb_shortcut(self, egui_ctx: &egui::Context) -> Option<String> {
471        // Note: we only show the primary shortcut to the user.
472        // The fallbacks are there for people who have muscle memory for the other shortcuts.
473        self.primary_kb_shortcut(egui_ctx.os())
474            .map(|shortcut| egui_ctx.format_shortcut(&shortcut))
475    }
476
477    pub fn icon(self) -> Option<&'static crate::Icon> {
478        match self {
479            Self::OpenWebHelp => Some(&crate::icons::EXTERNAL_LINK),
480            Self::OpenRerunDiscord => Some(&crate::icons::DISCORD),
481            _ => None,
482        }
483    }
484
485    pub fn is_link(self) -> bool {
486        matches!(self, Self::OpenWebHelp | Self::OpenRerunDiscord)
487    }
488
489    #[must_use = "Returns the Command that was triggered by some keyboard shortcut"]
490    pub fn listen_for_kb_shortcut(egui_ctx: &egui::Context) -> Option<Self> {
491        use strum::IntoEnumIterator as _;
492
493        let anything_has_focus = egui_ctx.memory(|mem| mem.focused().is_some());
494
495        let mut commands: Vec<(KeyboardShortcut, Self)> = Self::iter()
496            .flat_map(|cmd| {
497                cmd.kb_shortcuts(egui_ctx.os())
498                    .into_iter()
499                    .map(move |kb_shortcut| (kb_shortcut, cmd))
500            })
501            .collect();
502
503        // If the user pressed `Cmd-Shift-S` then egui will match that
504        // with both `Cmd-Shift-S` and `Cmd-S`.
505        // The reason is that `Shift` (and `Alt`) are sometimes required to produce certain keys,
506        // such as `+` (`Shift =` on an american keyboard).
507        // The result of this is that we must check for `Cmd-Shift-S` before `Cmd-S`, etc.
508        // So we order the commands here so that the commands with `Shift` and `Alt` in them
509        // are checked first.
510        commands.sort_by_key(|(kb_shortcut, _cmd)| {
511            let num_shift_alts =
512                kb_shortcut.modifiers.shift as i32 + kb_shortcut.modifiers.alt as i32;
513            -num_shift_alts // most first
514        });
515
516        egui_ctx.input_mut(|input| {
517            for (kb_shortcut, command) in commands {
518                if anything_has_focus {
519                    // If a text edit has focus, is should usually get exclusive access to that input.
520                    // For instance: use alt-arrows to move the cursor a whole word (at least on mac).
521                    // The exception are shortcuts with ctrl/cmd in them:
522                    let is_command = kb_shortcut.modifiers.command
523                        || kb_shortcut.modifiers.mac_cmd
524                        || kb_shortcut.modifiers.ctrl;
525                    if !is_command {
526                        continue; // ignore
527                    }
528                }
529
530                if input.consume_shortcut(&kb_shortcut) {
531                    // Clear the shortcut key from input to prevent it from propagating to other UI component.
532                    input.keys_down.remove(&kb_shortcut.logical_key);
533                    return Some(command);
534                }
535            }
536            None
537        })
538    }
539
540    /// Show this command as a menu-button.
541    ///
542    /// If clicked, enqueue the command.
543    pub fn menu_button_ui(
544        self,
545        ui: &mut egui::Ui,
546        command_sender: &impl UICommandSender,
547    ) -> egui::Response {
548        let button = self.menu_button(ui.ctx());
549        let mut response = ui.add(button).on_hover_text(self.tooltip());
550
551        if self.is_link() {
552            response = response.on_hover_cursor(egui::CursorIcon::PointingHand);
553        }
554
555        if response.clicked() {
556            command_sender.send_ui(self);
557            ui.close();
558        }
559
560        response
561    }
562
563    pub fn menu_button(self, egui_ctx: &egui::Context) -> egui::Button<'static> {
564        let tokens = egui_ctx.tokens();
565
566        let mut button = if let Some(icon) = self.icon() {
567            egui::Button::image_and_text(
568                icon.as_image()
569                    .tint(tokens.label_button_icon_color)
570                    .fit_to_exact_size(tokens.small_icon_size),
571                self.text(),
572            )
573        } else {
574            egui::Button::new(self.text())
575        };
576
577        if let Some(shortcut_text) = self.formatted_kb_shortcut(egui_ctx) {
578            button = button.shortcut_text(shortcut_text);
579        }
580
581        button
582    }
583
584    /// Show name of command and how to activate it
585    pub fn tooltip_ui(self, ui: &mut egui::Ui) {
586        let os = ui.ctx().os();
587
588        let (label, details) = self.text_and_tooltip();
589
590        if let Some(shortcut) = self.primary_kb_shortcut(os) {
591            crate::Help::new_without_title()
592                .control(label, crate::IconText::from_keyboard_shortcut(os, shortcut))
593                .ui(ui);
594        } else {
595            ui.label(label);
596        }
597
598        ui.set_max_width(220.0);
599        ui.label(details);
600    }
601}
602
603#[test]
604fn check_for_clashing_command_shortcuts() {
605    fn clashes(a: KeyboardShortcut, b: KeyboardShortcut) -> bool {
606        if a.logical_key != b.logical_key {
607            return false;
608        }
609
610        if a.modifiers.alt != b.modifiers.alt {
611            return false;
612        }
613
614        if a.modifiers.shift != b.modifiers.shift {
615            return false;
616        }
617
618        // On Non-Mac, command is interpreted as ctrl!
619        (a.modifiers.command || a.modifiers.ctrl) == (b.modifiers.command || b.modifiers.ctrl)
620    }
621
622    use strum::IntoEnumIterator as _;
623
624    for os in [
625        OperatingSystem::Mac,
626        OperatingSystem::Windows,
627        OperatingSystem::Nix,
628    ] {
629        for a_cmd in UICommand::iter() {
630            for a_shortcut in a_cmd.kb_shortcuts(os) {
631                for b_cmd in UICommand::iter() {
632                    if a_cmd == b_cmd {
633                        continue;
634                    }
635                    for b_shortcut in b_cmd.kb_shortcuts(os) {
636                        assert!(
637                            !clashes(a_shortcut, b_shortcut),
638                            "Command '{a_cmd:?}' and '{b_cmd:?}' have overlapping keyboard shortcuts: {:?} vs {:?}",
639                            a_shortcut.format(&egui::ModifierNames::NAMES, true),
640                            b_shortcut.format(&egui::ModifierNames::NAMES, true),
641                        );
642                    }
643                }
644            }
645        }
646    }
647}