1use egui::{Key, KeyboardShortcut, Modifiers, os::OperatingSystem};
2use smallvec::{SmallVec, smallvec};
3
4use crate::context_ext::ContextExt as _;
5
6pub trait UICommandSender {
8 fn send_ui(&self, command: UICommand);
9}
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, strum_macros::EnumIter)]
17pub enum UICommand {
18 Open,
20 OpenUrl,
21 Import,
22
23 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 PlaybackTogglePlayPause,
74 PlaybackFollow,
75 PlaybackStepBack,
76 PlaybackStepForward,
77 PlaybackRestart,
78
79 #[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 #[cfg(target_arch = "wasm32")]
101 RestartWithWebGl,
102 #[cfg(target_arch = "wasm32")]
103 RestartWithWebGpu,
104
105 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 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 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 pub fn primary_kb_shortcut(self, os: OperatingSystem) -> Option<KeyboardShortcut> {
465 self.kb_shortcuts(os).first().copied()
466 }
467
468 pub fn formatted_kb_shortcut(self, egui_ctx: &egui::Context) -> Option<String> {
471 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 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 });
515
516 egui_ctx.input_mut(|input| {
517 for (kb_shortcut, command) in commands {
518 if anything_has_focus {
519 let is_command = kb_shortcut.modifiers.command
523 || kb_shortcut.modifiers.mac_cmd
524 || kb_shortcut.modifiers.ctrl;
525 if !is_command {
526 continue; }
528 }
529
530 if input.consume_shortcut(&kb_shortcut) {
531 input.keys_down.remove(&kb_shortcut.logical_key);
533 return Some(command);
534 }
535 }
536 None
537 })
538 }
539
540 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 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 (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}