Files
oak-editor/src/actions.rs
T
Mike-Solar e0aa597f1e feat(app): custom shortcuts, Keyboard preferences tab, Action Search
- Shortcut override layer over the action registry: <config>/shortcuts
  file (id<TAB>keystroke, gpui syntax), loaded before bind_keys at
  startup, saved as diff-only (all-default removes the file), conflict
  resolution steals the key from its previous owner; rebind_keys
  applies changes live (clear_key_bindings + bind_keys + menu rebuild).
- Preferences gains a Keyboard tab: menu-hierarchy action list,
  name/path/shortcut filter, click-to-capture key editor (any key
  assigns, Backspace unbinds, Esc cancels), Reset Selected/All,
  Import/Export.
- Action Search on '/': modal listing 'Menu > Submenu > Action',
  live filter, arrows + Enter dispatch through the same path as menu
  clicks. Keystroke interception handles capture/search input ahead of
  the global keymap; modal opening is deferred to avoid a nested
  update_window failure.
2026-08-18 23:09:53 +08:00

1077 lines
48 KiB
Rust

// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The action registry: the single data source behind the menu bar, the
//! keyboard shortcuts and (later) the shortcut preferences page and the
//! action search dialog — the Rust counterpart of the C++ `MainMenu` +
//! `MenuShared` action system (`app/window/mainwindow/mainmenu.cpp`,
//! `app/widget/menu/menushared.cpp`).
//!
//! Every entry pairs a gpui action (defined through the [`gpui::actions`]
//! macro) with the C++ action's stable id string (`newproj`, `rippledelete`,
//! `snapping`, … — kept for future shortcut-file compatibility), its i18n
//! key, its default key(s) in gpui keystroke syntax (`secondary-` is the
//! platform command key) and a routing target:
//!
//! * [`Route::Global`] — the app shell handles it (file dialogs, undo,
//! preferences, tool selection, …);
//! * [`Route::FocusedPanel`] — the command goes to the currently focused
//! panel through [`crate::panels::commands::PanelCommandHandler`] first,
//! falling back to the shell's global handler when the panel does not
//! implement it (the C++ `PanelManager::currently_focused()` pattern).
//!
//! Menu clicks and key presses dispatch through the same path: the menu bar
//! reports the item id, the keymap dispatches the gpui action, and both end
//! up in `OakApp::dispatch_action_id`.
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use gpui::{Action, KeyBinding};
// One macro call generates all three views of the registry, so they can
// never drift apart: the gpui action structs, the `ActionId` enum and the
// `REGISTRY` table. Menu ids reuse the pre-action-system values for the
// actions that existed before (the tests' `menu_ids` constants).
macro_rules! define_actions {
($(
$name:ident {
cpp: $cpp:literal,
i18n: $i18n:literal,
keys: [$($key:literal),*],
route: $route:ident,
menu_id: $menu_id:expr
}
);* ;) => {
gpui::actions!(oak, [ $($name),* ]);
/// The stable identity of every app action (registry index).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ActionId {
$($name),*
}
impl ActionId {
/// The menu item id this action dispatches from (the menu bar's
/// numeric item ids; unique across the whole menu tree).
pub const fn menu_id(self) -> usize {
match self {
$( ActionId::$name => $menu_id ),*
}
}
/// This action's registry entry.
pub fn entry(self) -> &'static ActionEntry {
REGISTRY
.iter()
.find(|entry| entry.action == self)
.expect("every ActionId is in the registry")
}
}
/// The action registry, in menu order.
pub const REGISTRY: &[ActionEntry] = &[
$( ActionEntry {
action: ActionId::$name,
cpp_id: $cpp,
i18n_key: $i18n,
default_keys: &[$($key),*],
route: Route::$route,
build: || Box::new($name),
} ),*
];
};
}
define_actions! {
// --- File --------------------------------------------------------------
NewProject { cpp: "newproj", i18n: "menu.file.new_project", keys: ["secondary-n"], route: Global, menu_id: 101 };
NewSequence { cpp: "newseq", i18n: "menu.file.new_sequence", keys: ["secondary-shift-n"], route: Global, menu_id: 1001 };
NewFolder { cpp: "newfolder", i18n: "menu.file.new_folder", keys: [], route: Global, menu_id: 1002 };
OpenProject { cpp: "openproj", i18n: "menu.file.open_project", keys: ["secondary-o"], route: Global, menu_id: 102 };
OpenFromLibrary { cpp: "openlibrary", i18n: "menu.file.open_library", keys: [], route: Global, menu_id: 110 };
ClearOpenRecent { cpp: "clearopenrecent", i18n: "menu.file.clear_recent", keys: [], route: Global, menu_id: 1003 };
SaveProject { cpp: "saveproj", i18n: "menu.file.export_project", keys: ["secondary-s"], route: Global, menu_id: 103 };
SaveProjectAs { cpp: "saveprojas", i18n: "menu.file.save_as", keys: ["secondary-shift-s"], route: Global, menu_id: 1004 };
Revert { cpp: "revert", i18n: "menu.file.revert", keys: ["f12"], route: Global, menu_id: 1005 };
Import { cpp: "import", i18n: "menu.file.import_footage", keys: ["secondary-i"], route: Global, menu_id: 108 };
Export { cpp: "export", i18n: "menu.file.export_media", keys: ["secondary-m"], route: Global, menu_id: 106 };
ProjectProperties { cpp: "projectproperties", i18n: "menu.file.project_properties", keys: ["shift-f10"], route: Global, menu_id: 1006 };
CloseProject { cpp: "closeproj", i18n: "menu.file.close", keys: [], route: Global, menu_id: 105 };
ProjectManager { cpp: "projectmanager", i18n: "menu.file.project_manager", keys: [], route: Global, menu_id: 109 };
Exit { cpp: "exit", i18n: "menu.file.quit", keys: ["secondary-q"], route: Global, menu_id: 107 };
// --- Edit ---------------------------------------------------------------
Undo { cpp: "undo", i18n: "menu.edit.undo", keys: ["secondary-z"], route: Global, menu_id: 201 };
Redo { cpp: "redo", i18n: "menu.edit.redo", keys: ["secondary-shift-z"], route: Global, menu_id: 202 };
Cut { cpp: "cut", i18n: "menu.edit.cut", keys: ["secondary-x"], route: FocusedPanel, menu_id: 203 };
Copy { cpp: "copy", i18n: "menu.edit.copy", keys: ["secondary-c"], route: FocusedPanel, menu_id: 204 };
Paste { cpp: "paste", i18n: "menu.edit.paste", keys: ["secondary-v"], route: FocusedPanel, menu_id: 205 };
PasteInsert { cpp: "pasteinsert", i18n: "menu.edit.paste_insert", keys: ["secondary-shift-v"], route: FocusedPanel, menu_id: 1010 };
Duplicate { cpp: "duplicate", i18n: "menu.edit.duplicate", keys: ["secondary-d"], route: FocusedPanel, menu_id: 1011 };
Rename { cpp: "rename", i18n: "menu.edit.rename", keys: ["f2"], route: FocusedPanel, menu_id: 1012 };
Delete { cpp: "delete", i18n: "menu.edit.delete", keys: ["delete", "backspace"], route: FocusedPanel, menu_id: 206 };
RippleDelete { cpp: "rippledelete", i18n: "menu.edit.ripple_delete", keys: ["shift-delete", "shift-backspace"], route: FocusedPanel, menu_id: 207 };
SplitAtPlayhead { cpp: "split", i18n: "menu.edit.split", keys: ["secondary-k"], route: FocusedPanel, menu_id: 504 };
SpeedDuration { cpp: "speeddur", i18n: "menu.edit.speed_duration", keys: ["secondary-r"], route: FocusedPanel, menu_id: 1014 };
DefaultTransition { cpp: "deftransition", i18n: "menu.edit.default_transition", keys: ["secondary-shift-d"], route: FocusedPanel, menu_id: 1015 };
LinkUnlink { cpp: "linkunlink", i18n: "menu.edit.link_unlink", keys: ["secondary-l"], route: FocusedPanel, menu_id: 1016 };
EnableDisable { cpp: "enabledisable", i18n: "menu.edit.enable_disable", keys: ["shift-e"], route: FocusedPanel, menu_id: 1017 };
Nest { cpp: "nest", i18n: "menu.edit.nest", keys: [], route: FocusedPanel, menu_id: 1018 };
SelectAll { cpp: "selectall", i18n: "menu.edit.select_all", keys: ["secondary-a"], route: FocusedPanel, menu_id: 208 };
DeselectAll { cpp: "deselectall", i18n: "menu.edit.deselect_all", keys: ["secondary-shift-a"], route: FocusedPanel, menu_id: 1019 };
Insert { cpp: "insert", i18n: "menu.edit.insert", keys: [","], route: FocusedPanel, menu_id: 1020 };
Overwrite { cpp: "overwrite", i18n: "menu.edit.overwrite", keys: ["."], route: FocusedPanel, menu_id: 1021 };
RippleToIn { cpp: "rippletoin", i18n: "menu.edit.ripple_to_in", keys: ["q"], route: FocusedPanel, menu_id: 1022 };
RippleToOut { cpp: "rippletoout", i18n: "menu.edit.ripple_to_out", keys: ["w"], route: FocusedPanel, menu_id: 1023 };
EditToIn { cpp: "edittoin", i18n: "menu.edit.edit_to_in", keys: ["secondary-alt-q"], route: FocusedPanel, menu_id: 1024 };
EditToOut { cpp: "edittoout", i18n: "menu.edit.edit_to_out", keys: ["secondary-alt-w"], route: FocusedPanel, menu_id: 1025 };
NudgeLeft { cpp: "nudgeleft", i18n: "menu.edit.nudge_left", keys: ["alt-left"], route: FocusedPanel, menu_id: 1026 };
NudgeRight { cpp: "nudgeright", i18n: "menu.edit.nudge_right", keys: ["alt-right"], route: FocusedPanel, menu_id: 1027 };
MoveInToPlayhead { cpp: "moveintoplayhead", i18n: "menu.edit.move_in_to_playhead", keys: ["["], route: FocusedPanel, menu_id: 1028 };
MoveOutToPlayhead { cpp: "moveouttoplayhead", i18n: "menu.edit.move_out_to_playhead", keys: ["]"], route: FocusedPanel, menu_id: 1029 };
SetInPoint { cpp: "setinpoint", i18n: "menu.edit.set_in_point", keys: ["i"], route: FocusedPanel, menu_id: 407 };
SetOutPoint { cpp: "setoutpoint", i18n: "menu.edit.set_out_point", keys: ["o"], route: FocusedPanel, menu_id: 408 };
ResetIn { cpp: "resetin", i18n: "menu.edit.reset_in", keys: [], route: FocusedPanel, menu_id: 1030 };
ResetOut { cpp: "resetout", i18n: "menu.edit.reset_out", keys: [], route: FocusedPanel, menu_id: 1031 };
ClearInOut { cpp: "clearinout", i18n: "menu.edit.clear_in_out", keys: ["g"], route: FocusedPanel, menu_id: 1032 };
DeleteInOut { cpp: "deleteinout", i18n: "menu.edit.delete_in_out", keys: [";"], route: FocusedPanel, menu_id: 1033 };
RippleDeleteInOut { cpp: "rippledeleteinout", i18n: "menu.edit.ripple_delete_in_out", keys: ["'"], route: FocusedPanel, menu_id: 1034 };
Marker { cpp: "marker", i18n: "menu.edit.marker", keys: ["m"], route: FocusedPanel, menu_id: 505 };
// --- View ---------------------------------------------------------------
ZoomIn { cpp: "zoomin", i18n: "menu.view.zoom_in", keys: ["=", "shift-="], route: FocusedPanel, menu_id: 306 };
ZoomOut { cpp: "zoomout", i18n: "menu.view.zoom_out", keys: ["-"], route: FocusedPanel, menu_id: 307 };
IncreaseTrackHeight { cpp: "vzoomin", i18n: "menu.view.increase_track_height", keys: ["secondary-="], route: FocusedPanel, menu_id: 1040 };
DecreaseTrackHeight { cpp: "vzoomout", i18n: "menu.view.decrease_track_height", keys: ["secondary--"], route: FocusedPanel, menu_id: 1041 };
ToggleShowAll { cpp: "showall", i18n: "menu.view.show_all", keys: ["\\"], route: FocusedPanel, menu_id: 1042 };
FullScreen { cpp: "fullscreen", i18n: "menu.view.full_screen", keys: ["f11"], route: Global, menu_id: 1043 };
FullScreenViewer { cpp: "fullscreenviewer", i18n: "menu.view.full_screen_viewer", keys: [], route: FocusedPanel, menu_id: 1044 };
ThemeDark { cpp: "themedark", i18n: "menu.view.theme.dark", keys: [], route: Global, menu_id: 301 };
ThemeLight { cpp: "themelight", i18n: "menu.view.theme.light", keys: [], route: Global, menu_id: 302 };
LangZh { cpp: "langzh", i18n: "menu.view.language.zh", keys: [], route: Global, menu_id: 303 };
LangEn { cpp: "langen", i18n: "menu.view.language.en", keys: [], route: Global, menu_id: 304 };
// --- Playback -----------------------------------------------------------
GoToStart { cpp: "gotostart", i18n: "menu.playback.to_start", keys: ["home"], route: FocusedPanel, menu_id: 404 };
PrevFrame { cpp: "prevframe", i18n: "menu.playback.prev_frame", keys: ["left"], route: FocusedPanel, menu_id: 402 };
PlayPause { cpp: "playpause", i18n: "menu.playback.play_pause", keys: ["space"], route: FocusedPanel, menu_id: 401 };
PlayInToOut { cpp: "playintoout", i18n: "menu.playback.play_in_to_out", keys: ["shift-space"], route: FocusedPanel, menu_id: 1050 };
NextFrame { cpp: "nextframe", i18n: "menu.playback.next_frame", keys: ["right"], route: FocusedPanel, menu_id: 403 };
GoToEnd { cpp: "gotoend", i18n: "menu.playback.go_to_end", keys: ["end"], route: FocusedPanel, menu_id: 1051 };
GoToPrevCut { cpp: "prevcut", i18n: "menu.playback.prev_cut", keys: ["up"], route: FocusedPanel, menu_id: 1052 };
GoToNextCut { cpp: "nextcut", i18n: "menu.playback.next_cut", keys: ["down"], route: FocusedPanel, menu_id: 1053 };
GoToIn { cpp: "gotoin", i18n: "menu.playback.go_to_in", keys: ["shift-i"], route: FocusedPanel, menu_id: 1054 };
GoToOut { cpp: "gotoout", i18n: "menu.playback.go_to_out", keys: ["shift-o"], route: FocusedPanel, menu_id: 1055 };
ShuttleLeft { cpp: "decspeed", i18n: "menu.playback.shuttle_left", keys: ["j"], route: FocusedPanel, menu_id: 1056 };
ShuttleStop { cpp: "pause", i18n: "menu.playback.shuttle_stop", keys: ["k"], route: FocusedPanel, menu_id: 406 };
ShuttleRight { cpp: "incspeed", i18n: "menu.playback.shuttle_right", keys: ["l"], route: FocusedPanel, menu_id: 405 };
Loop { cpp: "loop", i18n: "menu.playback.loop", keys: [], route: Global, menu_id: 1057 };
// --- Sequence -----------------------------------------------------------
AddVideoTrack { cpp: "addvideotrack", i18n: "menu.sequence.add_video_track", keys: [], route: Global, menu_id: 501 };
AddAudioTrack { cpp: "addaudiotrack", i18n: "menu.sequence.add_audio_track", keys: [], route: Global, menu_id: 502 };
RemoveTrack { cpp: "removetrack", i18n: "menu.sequence.remove_track", keys: [], route: Global, menu_id: 503 };
SetWorkArea { cpp: "setworkarea", i18n: "menu.sequence.set_workarea", keys: [], route: Global, menu_id: 507 };
ClearWorkArea { cpp: "clearworkarea", i18n: "menu.sequence.clear_workarea", keys: [], route: Global, menu_id: 508 };
RemoveMarker { cpp: "removemarker", i18n: "menu.sequence.remove_marker", keys: [], route: Global, menu_id: 506 };
SeqCache { cpp: "seqcache", i18n: "menu.sequence.cache", keys: [], route: Global, menu_id: 1060 };
SeqCacheInOut { cpp: "seqcacheinout", i18n: "menu.sequence.cache_in_out", keys: [], route: Global, menu_id: 1061 };
SeqCacheClear { cpp: "seqcacheclear", i18n: "menu.sequence.cache_clear", keys: [], route: Global, menu_id: 1062 };
SequenceSettings { cpp: "seqsettings", i18n: "menu.sequence.settings", keys: [], route: Global, menu_id: 704 };
// --- Window -------------------------------------------------------------
FocusProject { cpp: "focusproject", i18n: "menu.window.project", keys: [], route: Global, menu_id: 601 };
FocusSourceViewer { cpp: "focussourceviewer", i18n: "menu.window.source_viewer", keys: [], route: Global, menu_id: 602 };
FocusProgramViewer { cpp: "focusprogramviewer", i18n: "menu.window.program_viewer", keys: [], route: Global, menu_id: 603 };
FocusNodeEditor { cpp: "focusnodeeditor", i18n: "menu.window.node_editor", keys: [], route: Global, menu_id: 604 };
FocusInspector { cpp: "focusinspector", i18n: "menu.window.inspector", keys: [], route: Global, menu_id: 605 };
FocusHistory { cpp: "focushistory", i18n: "menu.window.history", keys: [], route: Global, menu_id: 606 };
FocusTimeline { cpp: "focustimeline", i18n: "menu.window.timeline", keys: [], route: Global, menu_id: 607 };
FocusEffectLibrary { cpp: "focuseffectlibrary", i18n: "menu.window.effect_library", keys: [], route: Global, menu_id: 608 };
FocusMulticam { cpp: "focusmulticam", i18n: "menu.window.multicam", keys: [], route: Global, menu_id: 609 };
MaximizePanel { cpp: "maximizepanel", i18n: "menu.window.maximize_panel", keys: ["`"], route: Global, menu_id: 1070 };
ResetDefaultLayout { cpp: "resetdefaultlayout", i18n: "menu.window.reset_layout", keys: [], route: Global, menu_id: 1071 };
// --- Tools (the mutually exclusive tool group + snapping + proxy) -------
PointerTool { cpp: "pointertool", i18n: "menu.tools.pointer", keys: ["v"], route: Global, menu_id: 1080 };
TrackSelectTool { cpp: "trackselecttool", i18n: "menu.tools.track_select", keys: ["d"], route: Global, menu_id: 1081 };
EditTool { cpp: "edittool", i18n: "menu.tools.edit", keys: ["x"], route: Global, menu_id: 1082 };
RippleTool { cpp: "rippletool", i18n: "menu.tools.ripple", keys: ["b"], route: Global, menu_id: 1083 };
RollingTool { cpp: "rollingtool", i18n: "menu.tools.rolling", keys: ["n"], route: Global, menu_id: 1084 };
RazorTool { cpp: "razortool", i18n: "menu.tools.razor_tool", keys: ["c"], route: Global, menu_id: 1085 };
SlipTool { cpp: "sliptool", i18n: "menu.tools.slip", keys: ["y"], route: Global, menu_id: 1086 };
SlideTool { cpp: "slidetool", i18n: "menu.tools.slide", keys: ["u"], route: Global, menu_id: 1087 };
HandTool { cpp: "handtool", i18n: "menu.tools.hand", keys: ["h"], route: Global, menu_id: 1088 };
ZoomTool { cpp: "zoomtool", i18n: "menu.tools.zoom_tool", keys: ["z"], route: Global, menu_id: 1089 };
TransitionTool { cpp: "transitiontool", i18n: "menu.tools.transition", keys: ["t"], route: Global, menu_id: 1090 };
AddTool { cpp: "addtool", i18n: "menu.tools.add", keys: ["a"], route: Global, menu_id: 1091 };
RecordTool { cpp: "recordtool", i18n: "menu.tools.record", keys: ["r"], route: Global, menu_id: 1092 };
AddEmpty { cpp: "add:empty", i18n: "menu.tools.addable.empty", keys: [], route: Global, menu_id: 1100 };
AddBars { cpp: "add:bars", i18n: "menu.tools.addable.bars", keys: [], route: Global, menu_id: 1101 };
AddShape { cpp: "add:shape", i18n: "menu.tools.addable.shape", keys: [], route: Global, menu_id: 1102 };
AddSolid { cpp: "add:solid", i18n: "menu.tools.addable.solid", keys: [], route: Global, menu_id: 1103 };
AddTitle { cpp: "add:title", i18n: "menu.tools.addable.title", keys: [], route: Global, menu_id: 1104 };
AddTone { cpp: "add:tone", i18n: "menu.tools.addable.tone", keys: [], route: Global, menu_id: 1105 };
AddSubtitle { cpp: "add:subtitle", i18n: "menu.tools.addable.subtitle", keys: [], route: Global, menu_id: 1106 };
Snapping { cpp: "snapping", i18n: "menu.tools.snapping", keys: ["s"], route: Global, menu_id: 1110 };
UseProxyMedia { cpp: "useproxymedia", i18n: "menu.tools.use_proxy", keys: [], route: Global, menu_id: 1111 };
ProxySettings { cpp: "proxysettings", i18n: "menu.tools.proxy_settings", keys: [], route: Global, menu_id: 1112 };
// The timeline clip context menu's synchronize entries (context-menu
// only — they appear in the clip menu, not the menu bar).
SyncBySourceTime { cpp: "syncsourcetime", i18n: "timeline.context.sync_source_time", keys: [], route: FocusedPanel, menu_id: 1130 };
SyncByWaveform { cpp: "syncwaveform", i18n: "timeline.context.sync_waveform", keys: ["ctrl-shift-w"], route: FocusedPanel, menu_id: 1131 };
SyncByWaveformSpeed { cpp: "syncwaveformspeed", i18n: "timeline.context.sync_waveform_speed", keys: [], route: FocusedPanel, menu_id: 1132 };
// The multicam source-switch hotkeys. Like the C++ `QShortcut`s attached
// directly to the `MulticamWidget`, they are panel-context hotkeys, not
// menu items: `menu_id` is [`HIDDEN_MENU_ID`] and the menus never list
// them. They route to the focused panel (the multicam panel handles
// them; any other focused panel falls through to the no-op global
// handler). The digit keys switch and split the clip (the C++ plain
// `1`..`9`); the `secondary-` variants switch without splitting
// (`Ctrl+1`..`Ctrl+9`).
MulticamSwitch1 { cpp: "multicamswitch1", i18n: "multicam.switch_1", keys: ["1"], route: FocusedPanel, menu_id: 0 };
MulticamSwitch2 { cpp: "multicamswitch2", i18n: "multicam.switch_2", keys: ["2"], route: FocusedPanel, menu_id: 0 };
MulticamSwitch3 { cpp: "multicamswitch3", i18n: "multicam.switch_3", keys: ["3"], route: FocusedPanel, menu_id: 0 };
MulticamSwitch4 { cpp: "multicamswitch4", i18n: "multicam.switch_4", keys: ["4"], route: FocusedPanel, menu_id: 0 };
MulticamSwitch5 { cpp: "multicamswitch5", i18n: "multicam.switch_5", keys: ["5"], route: FocusedPanel, menu_id: 0 };
MulticamSwitch6 { cpp: "multicamswitch6", i18n: "multicam.switch_6", keys: ["6"], route: FocusedPanel, menu_id: 0 };
MulticamSwitch7 { cpp: "multicamswitch7", i18n: "multicam.switch_7", keys: ["7"], route: FocusedPanel, menu_id: 0 };
MulticamSwitch8 { cpp: "multicamswitch8", i18n: "multicam.switch_8", keys: ["8"], route: FocusedPanel, menu_id: 0 };
MulticamSwitch9 { cpp: "multicamswitch9", i18n: "multicam.switch_9", keys: ["9"], route: FocusedPanel, menu_id: 0 };
MulticamSwitchNoSplit1 { cpp: "multicamswitch1nosplit", i18n: "multicam.switch_1", keys: ["secondary-1"], route: FocusedPanel, menu_id: 0 };
MulticamSwitchNoSplit2 { cpp: "multicamswitch2nosplit", i18n: "multicam.switch_2", keys: ["secondary-2"], route: FocusedPanel, menu_id: 0 };
MulticamSwitchNoSplit3 { cpp: "multicamswitch3nosplit", i18n: "multicam.switch_3", keys: ["secondary-3"], route: FocusedPanel, menu_id: 0 };
MulticamSwitchNoSplit4 { cpp: "multicamswitch4nosplit", i18n: "multicam.switch_4", keys: ["secondary-4"], route: FocusedPanel, menu_id: 0 };
MulticamSwitchNoSplit5 { cpp: "multicamswitch5nosplit", i18n: "multicam.switch_5", keys: ["secondary-5"], route: FocusedPanel, menu_id: 0 };
MulticamSwitchNoSplit6 { cpp: "multicamswitch6nosplit", i18n: "multicam.switch_6", keys: ["secondary-6"], route: FocusedPanel, menu_id: 0 };
MulticamSwitchNoSplit7 { cpp: "multicamswitch7nosplit", i18n: "multicam.switch_7", keys: ["secondary-7"], route: FocusedPanel, menu_id: 0 };
MulticamSwitchNoSplit8 { cpp: "multicamswitch8nosplit", i18n: "multicam.switch_8", keys: ["secondary-8"], route: FocusedPanel, menu_id: 0 };
MulticamSwitchNoSplit9 { cpp: "multicamswitch9nosplit", i18n: "multicam.switch_9", keys: ["secondary-9"], route: FocusedPanel, menu_id: 0 };
Preferences { cpp: "prefs", i18n: "menu.view.preferences", keys: ["secondary-,"], route: Global, menu_id: 305 };
// --- Help ---------------------------------------------------------------
ActionSearch { cpp: "actionsearch", i18n: "menu.help.action_search", keys: ["/"], route: Global, menu_id: 1120 };
Feedback { cpp: "feedback", i18n: "menu.help.feedback", keys: [], route: Global, menu_id: 1121 };
About { cpp: "about", i18n: "menu.help.about", keys: [], route: Global, menu_id: 801 };
}
/// Where an action is dispatched.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Route {
/// The app shell handles it directly.
Global,
/// The focused panel gets it first (through
/// [`crate::panels::commands::PanelCommandHandler`]); the shell's global
/// handler is the fallback when the panel does not implement it.
FocusedPanel,
}
/// One registry entry: everything the menu bar, the keymap and the (future)
/// shortcut preferences / action search need to know about an action.
pub struct ActionEntry {
/// The action's identity.
pub action: ActionId,
/// The stable C++ action id (`mainmenu.cpp` / `menushared.cpp`), kept
/// for future shortcut-file (`id\t键序`) compatibility.
pub cpp_id: &'static str,
/// The menu label's i18n key (present in both language tables).
pub i18n_key: &'static str,
/// The default key(s) in gpui keystroke syntax; the first one is the
/// one the menus display. Empty = unbound.
pub default_keys: &'static [&'static str],
/// Where the action routes (shell or focused panel).
pub route: Route,
/// Builds the gpui action value (for key bindings and dispatch).
pub build: fn() -> Box<dyn Action>,
}
impl ActionEntry {
/// The menu item id of this entry's action.
pub const fn menu_id(&self) -> usize {
self.action.menu_id()
}
}
/// The menu id of the panel-context hotkeys that have no menu item (the
/// multicam source-switch keys). [`ActionId::menu_id`] returns it for those
/// actions; the menus never build an item with this id, and
/// [`entry_for_menu_id`] refuses it so a stray menu dispatch can never hit
/// a hidden action.
pub const HIDDEN_MENU_ID: usize = 0;
/// The registry entry bound to a menu item id, if any. Menu ids
/// [`HIDDEN_MENU_ID`] (the panel-context hotkeys' placeholder) resolve to
/// `None`.
pub fn entry_for_menu_id(id: usize) -> Option<&'static ActionEntry> {
if id == HIDDEN_MENU_ID {
return None;
}
REGISTRY.iter().find(|entry| entry.action.menu_id() == id)
}
/// The global key bindings for every action's *effective* key (context
/// `None`: the shell dispatches them wherever the focus is, and the modal
/// guard in the shell's action listeners suppresses them while a dialog is
/// open). An unbound action contributes nothing.
pub fn key_bindings() -> Vec<KeyBinding> {
let mut bindings = Vec::new();
for entry in REGISTRY {
for key in effective_keys(entry) {
let binding = KeyBinding::load(
&key,
(entry.build)(),
None,
false,
None,
&gpui::DummyKeyboardMapper,
)
.unwrap_or_else(|_| panic!("invalid effective key {key:?} for {}", entry.cpp_id));
bindings.push(binding);
}
}
bindings
}
/// The menu-bar label for a keystroke pattern: macOS-style glyphs (⌘⇧⌥⌃)
/// plus arrow / space glyphs, e.g. `secondary-shift-z` → `⇧⌘Z` (on the
/// other platforms the same keys render as `Ctrl+Shift+Z`). The space key
/// name is localized through i18n (`shortcut.space`).
pub fn display_shortcut(action: ActionId) -> Option<String> {
let keys = effective_keys(action.entry());
let key = keys.first()?;
let keystroke =
gpui::Keystroke::parse(key).expect("effective keys parse (tests enforce it)");
// `secondary-` parses to `platform` on macOS and `control` elsewhere;
// the modifier renderers below follow the same split.
let macos = cfg!(target_os = "macos");
let mut label = String::new();
let mut push = |name: &str, glyph: char| {
if macos {
label.push(glyph);
} else {
label.push_str(name);
label.push('+');
}
};
if keystroke.modifiers.control {
push("Ctrl", '⌃');
}
if keystroke.modifiers.alt {
push("Alt", '⌥');
}
if keystroke.modifiers.shift {
push("Shift", '⇧');
}
if keystroke.modifiers.platform {
push("Win", '⌘');
}
match keystroke.key.as_str() {
"left" => label.push('←'),
"right" => label.push('→'),
"up" => label.push('↑'),
"down" => label.push('↓'),
"backspace" => label.push('⌫'),
"delete" => label.push_str("Del"),
"space" => label.push_str(crate::i18n::tr("shortcut.space")),
"home" => label.push_str("Home"),
"end" => label.push_str("End"),
other if other.len() == 1 => label.push_str(&other.to_uppercase()),
other => {
// Named keys (f12, tab, …): capitalize the f-number form.
if let Some(rest) = other.strip_prefix('f') {
label.push_str(&format!("F{rest}"));
} else {
label.push_str(&format!("{}{}", other[..1].to_uppercase(), &other[1..]));
}
}
}
Some(label)
}
// ---------------------------------------------------------------------------
// Custom shortcut overrides (the `<config>/shortcuts` file)
// ---------------------------------------------------------------------------
//
// The C++ `MainWindow` keeps a `<config>/shortcuts` text file of
// `id<TAB>keys` lines (mainwindow.cpp:768-863): each line overrides one
// action's key(s), the file is read at startup and written back only with the
// entries that differ from the defaults (an empty diff removes the file).
// This port keeps the same line shape but stores the key sequence in **gpui
// keystroke syntax** (`secondary-s`, `alt-shift-z`, `delete`, …) instead of
// Qt's `QKeySequence::toString` (`Ctrl+S`):
//
// * `id\tkey` — bind that single key;
// * `id\t` (empty value) — unbind the action entirely;
// * absent line — the registry defaults.
//
// That is a deliberate deviation from the C++ file (a C++ `Ctrl+S` line
// would not parse as a gpui keystroke and vice versa): the C++ *format* is
// kept, not the byte-level values. The key strings themselves are
// platform-independent — `cmd-`, `super-` and `win-` all parse to gpui's
// "platform" modifier on every OS, so a file written on macOS loads on
// Linux (and the canonical form used for comparisons normalizes the
// `secondary-` spelling used by the registry defaults).
//
// The override table is a process-global behind a mutex (like the config
// store): the menu bar, `key_bindings` and `display_shortcut` all read the
// effective keys on demand, so a change is visible the moment it is made and
// the shell's `rebind_keys` + `rebuild_menu_bar` turn it live.
/// One action's override:
/// * absent from the map — the registry defaults apply;
/// * [`ShortcutOverride::Unbound`] — explicitly unbound (no keys at all);
/// * [`ShortcutOverride::Keys`] — the custom key(s).
enum ShortcutOverride {
Unbound,
Keys(Vec<String>),
}
/// The process-global override table, keyed by the stable cpp id.
static OVERRIDES: OnceLock<Mutex<HashMap<String, ShortcutOverride>>> = OnceLock::new();
/// The global override table.
fn overrides() -> &'static Mutex<HashMap<String, ShortcutOverride>> {
OVERRIDES.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Serializes tests that touch the process-global override table (the same
/// pattern the i18n tests use for the language global).
#[cfg(test)]
pub(crate) fn shortcuts_test_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
/// The path of the custom-shortcuts file: `<config>/shortcuts`, exactly like
/// the C++ `MainWindow::get_custom_shortcuts_file`.
pub fn custom_shortcuts_path() -> String {
let dir = oakcommon::filefunctions::FileFunctions::new()
.get_configuration_location()
.unwrap_or_else(|_| std::env::temp_dir().to_string_lossy().into_owned());
format!("{}/shortcuts", dir.trim_end_matches('/'))
}
/// Parses one `id\tkey` line. `None` for blank lines, missing ids and keys
/// that are not valid gpui keystrokes. An empty key value means "unbound".
///
/// The tab is split *before* trimming (a bare trailing `\t` would otherwise
/// be trimmed away and the unbound marker lost); only the id and the key
/// value are trimmed.
fn parse_shortcut_line(line: &str) -> Option<(String, Vec<String>)> {
// `str::lines` strips `\n` but keeps the `\r` of CRLF files.
let line = line.strip_suffix('\r').unwrap_or(line);
if line.trim().is_empty() {
return None;
}
let (id, value) = line.split_once('\t')?; // a line without a tab is malformed
let id = id.trim();
if REGISTRY.iter().all(|entry| entry.cpp_id != id) {
return None;
}
let value = value.trim();
let keys = if value.is_empty() {
Vec::new()
} else {
if gpui::Keystroke::parse(value).is_err() {
return None;
}
vec![value.to_string()]
};
Some((id.to_string(), keys))
}
/// Parses a whole shortcuts file body (`id\tkey` lines; blank lines and
/// malformed lines are skipped).
pub fn parse_shortcut_file(contents: &str) -> Vec<(String, Vec<String>)> {
contents.lines().filter_map(parse_shortcut_line).collect()
}
/// Serializes override lines back to `id\tkey` (empty key = unbound).
pub fn serialize_shortcut_file(entries: &[(String, Vec<String>)]) -> String {
let mut out = String::new();
for (index, (id, keys)) in entries.iter().enumerate() {
if index > 0 {
out.push('\n');
}
out.push_str(id);
out.push('\t');
if let Some(key) = keys.first() {
out.push_str(key);
}
}
out
}
/// Replaces the whole override table with `entries` (startup load and the
/// Keyboard tab's Import button — anything not listed falls back to default).
pub fn apply_shortcut_overrides(entries: Vec<(String, Vec<String>)>) {
let mut map = HashMap::new();
for (id, keys) in entries {
map.insert(
id,
if keys.is_empty() {
ShortcutOverride::Unbound
} else {
ShortcutOverride::Keys(keys)
},
);
}
*overrides().lock().unwrap() = map;
}
/// Removes every override (the Keyboard tab's Reset All).
pub fn reset_all_custom_shortcuts() {
overrides().lock().unwrap().clear();
}
/// Removes the override of one action (back to the registry defaults).
pub fn reset_custom_shortcut(cpp_id: &str) {
overrides().lock().unwrap().remove(cpp_id);
}
/// Sets the custom keys of one action; an empty list unbinds it.
pub fn set_custom_shortcut(cpp_id: &str, keys: Vec<String>) {
overrides().lock().unwrap().insert(
cpp_id.to_string(),
if keys.is_empty() {
ShortcutOverride::Unbound
} else {
ShortcutOverride::Keys(keys)
},
);
}
/// Whether any override is currently set (tests / file presence checks).
pub fn has_custom_shortcuts() -> bool {
!overrides().lock().unwrap().is_empty()
}
/// The canonical (parse → unparse) form of a keystroke string, used to
/// compare keys regardless of the `secondary-` vs `cmd-`/`super-`/`win-`
/// spelling (the parser maps all of them to the same modifier bits).
fn canonical_key(key: &str) -> Option<String> {
gpui::Keystroke::parse(key).ok().map(|keystroke| keystroke.unparse())
}
/// The canonical form of a key list (accepts both `&str` and `String`
/// slices, so the `&'static [&str]` registry defaults and the `Vec<String>`
/// overrides share one code path).
fn canonical_keys<K: AsRef<str>>(keys: &[K]) -> Vec<String> {
keys.iter().filter_map(|key| canonical_key(key.as_ref())).collect()
}
/// The action's effective key list: the override when one is set, else the
/// registry defaults (empty = unbound).
pub fn effective_keys(entry: &ActionEntry) -> Vec<String> {
match overrides().lock().unwrap().get(entry.cpp_id) {
Some(ShortcutOverride::Unbound) => Vec::new(),
Some(ShortcutOverride::Keys(keys)) => keys.clone(),
None => entry.default_keys.iter().map(|key| key.to_string()).collect(),
}
}
/// The registry defaults in canonical form (the save-diff baseline).
fn default_canonical(entry: &ActionEntry) -> Vec<String> {
canonical_keys(entry.default_keys)
}
/// Loads `<config>/shortcuts` and applies it (startup). A missing file is a
/// no-op (everything stays at the defaults).
pub fn load_custom_shortcuts() {
let path = custom_shortcuts_path();
if let Ok(contents) = std::fs::read_to_string(&path) {
apply_shortcut_overrides(parse_shortcut_file(&contents));
}
}
/// Loads overrides from an explicit file path (the Keyboard tab's Import).
/// Returns the number of lines applied.
pub fn load_custom_shortcuts_from(path: &str) -> Result<usize, String> {
let contents = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
let entries = parse_shortcut_file(&contents);
let count = entries.len();
apply_shortcut_overrides(entries);
Ok(count)
}
/// Writes the overrides that differ from the registry defaults to `path`
/// (canonical comparison, so an override spelling `cmd-s` for a default
/// `secondary-s` counts as unchanged). An all-default state removes the
/// file, exactly like the C++. Returns the number of lines written.
pub fn save_custom_shortcuts_to(path: &str) -> Result<usize, String> {
let mut lines: Vec<(String, Vec<String>)> = Vec::new();
{
let map = overrides().lock().unwrap();
for entry in REGISTRY {
let Some(override_) = map.get(entry.cpp_id) else {
continue;
};
let effective = match override_ {
ShortcutOverride::Unbound => Vec::new(),
ShortcutOverride::Keys(keys) => keys.clone(),
};
if canonical_keys(&effective) != default_canonical(entry) {
lines.push((entry.cpp_id.to_string(), effective));
}
}
}
if lines.is_empty() {
match std::fs::remove_file(path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e.to_string()),
}
return Ok(0);
}
std::fs::write(path, serialize_shortcut_file(&lines)).map_err(|e| e.to_string())?;
Ok(lines.len())
}
/// Saves the diff to the configured location (every key-capture commit
/// calls this so the change survives restarts).
pub fn save_custom_shortcuts() -> Result<usize, String> {
save_custom_shortcuts_to(&custom_shortcuts_path())
}
/// The first registry action whose *effective* keys contain the canonical
/// `canon`, if any (the current owner of a key, for conflict detection).
pub fn owner_of_shortcut(canon: &str) -> Option<ActionId> {
REGISTRY
.iter()
.find(|entry| {
effective_keys(entry)
.iter()
.any(|key| canonical_key(key).as_deref() == Some(canon))
})
.map(|entry| entry.action)
}
/// Moves the canonical key `canon` away from every *other* action that
/// currently binds it, shrinking their override by one key (or unbinding
/// them). Returns the action the key was first taken from, if any — the
/// conflict policy: the new assignment wins and the displaced action falls
/// back to its remaining keys / to none. Simple and explicit (the alternative
/// — refusing the assignment — leaves the user stuck when the default keys
/// collide with a popular choice).
pub fn steal_shortcut_for(entry: &ActionEntry, canon: &str) -> Option<ActionId> {
let mut stolen = None;
for other in REGISTRY {
if other.action == entry.action {
continue;
}
let has = effective_keys(other)
.iter()
.any(|key| canonical_key(key).as_deref() == Some(canon));
if has {
let mut keys = effective_keys(other);
keys.retain(|key| canonical_key(key).as_deref() != Some(canon));
set_custom_shortcut(other.cpp_id, keys);
stolen.get_or_insert(other.action);
}
}
stolen
}
/// The timeline's editing tools (the C++ `Tool` enum's pointer tools): the
/// Tools menu's mutually exclusive group.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tool {
Pointer,
TrackSelect,
Edit,
Ripple,
Rolling,
Razor,
Slip,
Slide,
Hand,
Zoom,
Transition,
Add,
Record,
}
impl Tool {
/// The action selecting this tool.
pub const fn action(self) -> ActionId {
match self {
Tool::Pointer => ActionId::PointerTool,
Tool::TrackSelect => ActionId::TrackSelectTool,
Tool::Edit => ActionId::EditTool,
Tool::Ripple => ActionId::RippleTool,
Tool::Rolling => ActionId::RollingTool,
Tool::Razor => ActionId::RazorTool,
Tool::Slip => ActionId::SlipTool,
Tool::Slide => ActionId::SlideTool,
Tool::Hand => ActionId::HandTool,
Tool::Zoom => ActionId::ZoomTool,
Tool::Transition => ActionId::TransitionTool,
Tool::Add => ActionId::AddTool,
Tool::Record => ActionId::RecordTool,
}
}
/// The tool an action selects, if it is a tool action.
pub const fn from_action(action: ActionId) -> Option<Tool> {
match action {
ActionId::PointerTool => Some(Tool::Pointer),
ActionId::TrackSelectTool => Some(Tool::TrackSelect),
ActionId::EditTool => Some(Tool::Edit),
ActionId::RippleTool => Some(Tool::Ripple),
ActionId::RollingTool => Some(Tool::Rolling),
ActionId::RazorTool => Some(Tool::Razor),
ActionId::SlipTool => Some(Tool::Slip),
ActionId::SlideTool => Some(Tool::Slide),
ActionId::HandTool => Some(Tool::Hand),
ActionId::ZoomTool => Some(Tool::Zoom),
ActionId::TransitionTool => Some(Tool::Transition),
ActionId::AddTool => Some(Tool::Add),
ActionId::RecordTool => Some(Tool::Record),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Every stable id is unique (the shortcut-file format keys on it).
#[test]
fn registry_ids_are_unique() {
let mut seen = std::collections::HashSet::new();
for entry in REGISTRY {
assert!(seen.insert(entry.cpp_id), "duplicate cpp id {}", entry.cpp_id);
}
}
/// Every menu id is unique (the menu bar reports plain ids; a duplicate
/// would make two items dispatch the same action). Panel-context
/// hotkeys share [`HIDDEN_MENU_ID`] (no menu item) and are skipped.
#[test]
fn registry_menu_ids_are_unique() {
let mut seen = std::collections::HashSet::new();
for entry in REGISTRY {
if entry.menu_id() == HIDDEN_MENU_ID {
continue;
}
assert!(
seen.insert(entry.menu_id()),
"duplicate menu id {} ({})",
entry.menu_id(),
entry.cpp_id
);
}
}
/// Every default key parses as a gpui keystroke (the table is static
/// data, so a typo would otherwise surface only as a dead shortcut at
/// runtime — `key_bindings` would panic at startup).
#[test]
fn every_default_key_parses() {
for entry in REGISTRY {
for key in entry.default_keys {
assert!(
gpui::Keystroke::parse(key).is_ok(),
"invalid keystroke {key:?} on {}",
entry.cpp_id
);
}
}
}
/// No two actions claim the same keystroke (a conflict would make the
/// keymap dispatch whichever binding was registered last).
#[test]
fn default_keys_are_conflict_free() {
let mut seen = std::collections::HashMap::new();
for entry in REGISTRY {
for key in entry.default_keys {
let parsed = gpui::Keystroke::parse(key).unwrap();
let canon = parsed.unparse();
let previous = seen.insert(canon.clone(), entry.cpp_id);
assert!(
previous.is_none(),
"keystroke {canon} bound to both {} and {}",
previous.unwrap(),
entry.cpp_id
);
}
}
}
/// Every registry action appears somewhere in the menu tree (an action
/// without a menu entry is unreachable with the mouse, and the registry
/// is meant to drive the menus).
#[test]
fn every_action_appears_in_the_menu_tree() {
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
crate::i18n::set_language(crate::i18n::Language::EnUs);
fn collect(menu: &gpui_widgets::menu::Menu, out: &mut Vec<usize>) {
for item in &menu.items {
out.push(item.id);
if let Some(sub) = &item.submenu {
collect(sub, out);
}
}
}
let mut ids = Vec::new();
for entry in crate::app::make_menus_for_test() {
collect(&entry.menu, &mut ids);
}
// Context-menu-only actions (the timeline clip menu's synchronize
// group) appear in the clip menu instead of the menu bar.
collect(
&crate::panels::timeline::clip_menu(
crate::oakui::engine::SyncEligibility::default(),
&[],
None,
),
&mut ids,
);
for entry in REGISTRY {
// The panel-context hotkeys (multicam source switches) are bound
// to the focused panel, not to any menu — the C++ attaches them
// straight to the MulticamWidget.
if entry.menu_id() == HIDDEN_MENU_ID {
continue;
}
assert!(
ids.contains(&entry.menu_id()),
"action {} (menu id {}) has no menu item",
entry.cpp_id,
entry.menu_id()
);
}
}
/// Every registry i18n key exists in both language tables with a
/// non-empty value (the menu labels come straight from `tr`).
#[test]
fn every_i18n_key_exists_in_both_languages() {
for entry in REGISTRY {
for language in [crate::i18n::Language::EnUs, crate::i18n::Language::ZhCN] {
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
crate::i18n::set_language(language);
let value = crate::i18n::tr(entry.i18n_key);
assert_ne!(
value, entry.i18n_key,
"i18n key {} ({language:?}) is missing for action {}",
entry.i18n_key, entry.cpp_id
);
assert!(!value.is_empty());
}
}
}
/// The shortcut display formatter renders the documented labels for a
/// sample of shapes (modifier stacks, arrows, named keys, punctuation).
/// The glyph form is the macOS rendering; other platforms spell the
/// modifiers out (covered by the parser tests instead).
#[test]
#[cfg(target_os = "macos")]
fn display_shortcut_formats_labels() {
let _guard = shortcuts_test_lock().lock().unwrap();
assert_eq!(
display_shortcut(ActionId::Redo).as_deref(),
Some("⇧⌘Z")
);
assert_eq!(
display_shortcut(ActionId::SplitAtPlayhead).as_deref(),
Some("⌘K")
);
assert_eq!(
display_shortcut(ActionId::NudgeLeft).as_deref(),
Some("⌥←")
);
assert_eq!(
display_shortcut(ActionId::FullScreen).as_deref(),
Some("F11")
);
assert_eq!(
display_shortcut(ActionId::Insert).as_deref(),
Some(",")
);
assert!(display_shortcut(ActionId::About).is_none());
}
// -------------------------------------------------------------------
// Custom shortcut overrides (stage 7)
// -------------------------------------------------------------------
/// A unique temporary directory for one test (the `shortcuts` file
/// round-trip), so parallel tests never collide on the same path.
fn temp_dir(label: &str) -> String {
static COUNTER: std::sync::atomic::AtomicUsize =
std::sync::atomic::AtomicUsize::new(0);
let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"oak-shortcuts-test-{label}-{}-{n}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).unwrap();
path.to_string_lossy().into_owned()
}
/// Parsing skips blank lines, unknown ids and unparseable keystrokes; an
/// empty value after the tab means "unbound".
#[test]
fn parse_shortcut_file_skips_malformed_lines() {
let entries = parse_shortcut_file(
"newproj\tsecondary-n\n\nbogusaction\tsecondary-x\nundo\t\nsaveproj\tnot-a-key\ncut\tsecondary-c\n",
);
assert_eq!(
entries,
vec![
("newproj".to_string(), vec!["secondary-n".to_string()]),
("undo".to_string(), vec![]), // unbound
("cut".to_string(), vec!["secondary-c".to_string()]),
]
);
}
/// Write → clear → reload round-trips the effective keys, and the file
/// only carries the entries that differ from the registry defaults.
#[test]
fn shortcuts_file_round_trips_through_the_override_table() {
let _guard = shortcuts_test_lock().lock().unwrap();
reset_all_custom_shortcuts();
let dir = temp_dir("roundtrip");
let path = format!("{dir}/shortcuts");
// `newproj` = its own default (spelled in registry syntax) → not a
// diff; the other two differ (`undo` gets unbound).
set_custom_shortcut("newproj", vec!["secondary-n".to_string()]);
set_custom_shortcut("saveproj", vec!["cmd-alt-s".to_string()]);
set_custom_shortcut("undo", Vec::new()); // unbound
assert_eq!(save_custom_shortcuts_to(&path).unwrap(), 2);
let contents = std::fs::read_to_string(&path).unwrap();
assert!(contents.contains("saveproj\t"), "file: {contents}");
assert!(contents.contains("undo\t"), "file: {contents}");
assert!(!contents.contains("newproj"), "file: {contents}");
// Reload into a fresh table.
reset_all_custom_shortcuts();
assert!(!has_custom_shortcuts());
assert_eq!(load_custom_shortcuts_from(&path).unwrap(), 2);
assert_eq!(
effective_keys(ActionId::SaveProject.entry()),
vec!["cmd-alt-s".to_string()]
);
assert_eq!(
effective_keys(ActionId::Undo.entry()),
Vec::<String>::new()
);
// The un-overridden action keeps its registry default.
assert_eq!(
effective_keys(ActionId::NewProject.entry()),
vec!["secondary-n".to_string()]
);
}
/// A default-equal override is dropped on save (canonical comparison), and
/// an all-default table removes the file entirely (the C++ behavior).
#[test]
fn save_writes_only_entries_that_differ_from_default() {
let _guard = shortcuts_test_lock().lock().unwrap();
let dir = temp_dir("diff");
let path = format!("{dir}/shortcuts");
set_custom_shortcut("newproj", vec!["secondary-n".to_string()]);
set_custom_shortcut("undo", vec!["cmd-alt-z".to_string()]);
assert_eq!(save_custom_shortcuts_to(&path).unwrap(), 1);
let contents = std::fs::read_to_string(&path).unwrap();
assert!(contents.contains("undo\tcmd-alt-z"), "file: {contents}");
assert!(!contents.contains("newproj"), "file: {contents}");
// All-default → the file disappears.
reset_all_custom_shortcuts();
set_custom_shortcut("newproj", vec!["secondary-n".to_string()]);
assert_eq!(save_custom_shortcuts_to(&path).unwrap(), 0);
assert!(!std::path::Path::new(&path).exists());
}
/// Effective keys fall back to the registry defaults and the multi-key
/// defaults stay intact until overridden.
#[test]
fn effective_keys_fall_back_to_defaults() {
let _guard = shortcuts_test_lock().lock().unwrap();
reset_all_custom_shortcuts();
// Delete defaults to ["delete", "backspace"].
assert_eq!(
effective_keys(ActionId::Delete.entry()),
vec!["delete".to_string(), "backspace".to_string()]
);
set_custom_shortcut("delete", vec!["secondary-x".to_string()]);
assert_eq!(
effective_keys(ActionId::Delete.entry()),
vec!["secondary-x".to_string()]
);
reset_custom_shortcut("delete");
assert_eq!(
effective_keys(ActionId::Delete.entry()),
vec!["delete".to_string(), "backspace".to_string()]
);
}
/// Conflict resolution: taking a key away from another action moves the
/// binding (the displaced action falls back to its remaining keys, or to
/// none).
#[test]
fn stealing_a_key_moves_the_binding_away() {
let _guard = shortcuts_test_lock().lock().unwrap();
reset_all_custom_shortcuts();
let canon = canonical_key("secondary-c").expect("copy's key parses");
assert_eq!(owner_of_shortcut(&canon), Some(ActionId::Copy));
// The capture flow: steal the key away from its current owner, then
// assign it to the target.
let stolen = steal_shortcut_for(ActionId::Paste.entry(), &canon);
assert_eq!(stolen, Some(ActionId::Copy));
set_custom_shortcut(ActionId::Paste.entry().cpp_id, vec![canon.clone()]);
assert_eq!(owner_of_shortcut(&canon), Some(ActionId::Paste));
assert!(
effective_keys(ActionId::Copy.entry()).is_empty(),
"the displaced Copy must be unbound"
);
// A key nobody owns steals nothing.
reset_all_custom_shortcuts();
assert_eq!(steal_shortcut_for(ActionId::Paste.entry(), "f24"), None);
}
/// Overriding a default with the same key keeps `display_shortcut` stable
/// (the canonical comparison treats `secondary-` and `cmd-` as equal).
#[test]
#[cfg(target_os = "macos")]
fn display_shortcut_uses_the_effective_key() {
let _guard = shortcuts_test_lock().lock().unwrap();
set_custom_shortcut("saveproj", vec!["cmd-alt-s".to_string()]);
assert_eq!(
display_shortcut(ActionId::SaveProject).as_deref(),
Some("⌥⌘S")
);
set_custom_shortcut("saveproj", Vec::new()); // unbound → no label
assert!(display_shortcut(ActionId::SaveProject).is_none());
reset_all_custom_shortcuts();
}
}