use crate::config::Config;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use rust_i18n::t;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
fn normalize_key(code: KeyCode, modifiers: KeyModifiers) -> (KeyCode, KeyModifiers) {
if code == KeyCode::BackTab {
return (code, modifiers.difference(KeyModifiers::SHIFT));
}
if code == KeyCode::Backspace {
return (code, modifiers.difference(KeyModifiers::SHIFT));
}
if let KeyCode::Char(c) = code {
if c.is_ascii_uppercase() {
return (KeyCode::Char(c.to_ascii_lowercase()), modifiers);
}
}
(code, modifiers)
}
static FORCE_LINUX_KEYBINDINGS: AtomicBool = AtomicBool::new(false);
pub fn set_force_linux_keybindings(force: bool) {
FORCE_LINUX_KEYBINDINGS.store(force, Ordering::SeqCst);
}
fn use_macos_symbols() -> bool {
if FORCE_LINUX_KEYBINDINGS.load(Ordering::SeqCst) {
return false;
}
cfg!(target_os = "macos")
}
fn is_text_input_modifier(modifiers: KeyModifiers) -> bool {
if modifiers.is_empty() || modifiers == KeyModifiers::SHIFT {
return true;
}
#[cfg(windows)]
if modifiers == (KeyModifiers::CONTROL | KeyModifiers::ALT)
|| modifiers == (KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT)
{
return true;
}
false
}
pub fn format_keybinding(keycode: &KeyCode, modifiers: &KeyModifiers) -> String {
let mut result = String::new();
let (ctrl_label, alt_label, shift_label, super_label) = if use_macos_symbols() {
("⌃", "⌥", "⇧", "⌘")
} else {
("Ctrl", "Alt", "Shift", "Super")
};
let use_plus = !use_macos_symbols();
if modifiers.contains(KeyModifiers::SUPER) {
result.push_str(super_label);
if use_plus {
result.push('+');
}
}
if modifiers.contains(KeyModifiers::CONTROL) {
result.push_str(ctrl_label);
if use_plus {
result.push('+');
}
}
if modifiers.contains(KeyModifiers::ALT) {
result.push_str(alt_label);
if use_plus {
result.push('+');
}
}
if modifiers.contains(KeyModifiers::SHIFT) {
result.push_str(shift_label);
if use_plus {
result.push('+');
}
}
match keycode {
KeyCode::Enter => result.push_str("Enter"),
KeyCode::Backspace => result.push_str("Backspace"),
KeyCode::Delete => result.push_str("Del"),
KeyCode::Tab => result.push_str("Tab"),
KeyCode::Esc => result.push_str("Esc"),
KeyCode::Left => result.push('←'),
KeyCode::Right => result.push('→'),
KeyCode::Up => result.push('↑'),
KeyCode::Down => result.push('↓'),
KeyCode::Home => result.push_str("Home"),
KeyCode::End => result.push_str("End"),
KeyCode::PageUp => result.push_str("PgUp"),
KeyCode::PageDown => result.push_str("PgDn"),
KeyCode::Char(' ') => result.push_str("Space"),
KeyCode::Char(c) => result.push_str(&c.to_uppercase().to_string()),
KeyCode::F(n) => result.push_str(&format!("F{}", n)),
_ => return String::new(),
}
result
}
fn keybinding_priority_score(key: &KeyCode) -> u32 {
match key {
KeyCode::Char('@') => 100, KeyCode::Char('7') => 100, KeyCode::Char('_') => 100, _ => 0,
}
}
pub fn terminal_key_equivalents(
key: KeyCode,
modifiers: KeyModifiers,
) -> Vec<(KeyCode, KeyModifiers)> {
let mut equivalents = Vec::new();
if modifiers.contains(KeyModifiers::CONTROL) {
let base_modifiers = modifiers;
match key {
KeyCode::Char('/') => {
equivalents.push((KeyCode::Char('7'), base_modifiers));
}
KeyCode::Char('7') => {
equivalents.push((KeyCode::Char('/'), base_modifiers));
}
KeyCode::Backspace => {
equivalents.push((KeyCode::Char('h'), base_modifiers));
}
KeyCode::Char('h') if modifiers == KeyModifiers::CONTROL => {
equivalents.push((KeyCode::Backspace, base_modifiers));
}
KeyCode::Char(' ') => {
equivalents.push((KeyCode::Char('@'), base_modifiers));
}
KeyCode::Char('@') => {
equivalents.push((KeyCode::Char(' '), base_modifiers));
}
KeyCode::Char('-') => {
equivalents.push((KeyCode::Char('_'), base_modifiers));
}
KeyCode::Char('_') => {
equivalents.push((KeyCode::Char('-'), base_modifiers));
}
_ => {}
}
}
equivalents
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum KeyContext {
Global,
Normal,
Prompt,
Popup,
Completion,
FileExplorer,
Dock,
Menu,
Terminal,
Settings,
CompositeBuffer,
Mode(String),
}
impl KeyContext {
pub fn allows_normal_fallthrough(&self) -> bool {
matches!(self, Self::CompositeBuffer)
}
pub fn allows_ui_fallthrough(&self) -> bool {
matches!(self, Self::FileExplorer | Self::Dock | Self::Mode(_))
}
pub fn allows_text_input(&self) -> bool {
matches!(self, Self::Normal | Self::Prompt | Self::FileExplorer)
}
pub fn from_when_clause(when: &str) -> Option<Self> {
let trimmed = when.trim();
if let Some(mode_name) = trimmed.strip_prefix("mode:") {
return Some(Self::Mode(mode_name.to_string()));
}
Some(match trimmed {
"global" => Self::Global,
"prompt" => Self::Prompt,
"popup" => Self::Popup,
"completion" => Self::Completion,
"fileExplorer" | "file_explorer" => Self::FileExplorer,
"dock" => Self::Dock,
"normal" => Self::Normal,
"menu" => Self::Menu,
"terminal" => Self::Terminal,
"settings" => Self::Settings,
"compositeBuffer" | "composite_buffer" => Self::CompositeBuffer,
_ => return None,
})
}
pub fn to_when_clause(&self) -> String {
match self {
Self::Global => "global".to_string(),
Self::Normal => "normal".to_string(),
Self::Prompt => "prompt".to_string(),
Self::Popup => "popup".to_string(),
Self::Completion => "completion".to_string(),
Self::FileExplorer => "fileExplorer".to_string(),
Self::Dock => "dock".to_string(),
Self::Menu => "menu".to_string(),
Self::Terminal => "terminal".to_string(),
Self::Settings => "settings".to_string(),
Self::CompositeBuffer => "compositeBuffer".to_string(),
Self::Mode(name) => format!("mode:{}", name),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Action {
InsertChar(char),
InsertNewline,
InsertTab,
MoveLeft,
MoveRight,
MoveUp,
MoveDown,
MoveWordLeft,
MoveWordRight,
MoveWordEnd, ViMoveWordEnd, MoveLeftInLine, MoveRightInLine, MoveLineStart,
MoveLineEnd,
MoveLineUp,
MoveLineDown,
MoveToParagraphUp,
MoveToParagraphDown,
MovePageUp,
MovePageDown,
MoveDocumentStart,
MoveDocumentEnd,
SelectLeft,
SelectRight,
SelectUp,
SelectDown,
SelectToParagraphUp, SelectToParagraphDown, SelectWordLeft,
SelectWordRight,
SelectWordEnd, ViSelectWordEnd, SelectLineStart,
SelectLineEnd,
SelectDocumentStart,
SelectDocumentEnd,
SelectPageUp,
SelectPageDown,
SelectAll,
SelectWord,
SelectLine,
ExpandSelection,
BlockSelectLeft,
BlockSelectRight,
BlockSelectUp,
BlockSelectDown,
DeleteBackward,
DeleteForward,
DeleteWordBackward,
DeleteWordForward,
DeleteLine,
DeleteToLineEnd,
DeleteToLineStart,
DeleteViWordEnd, TransposeChars,
OpenLine,
DuplicateLine,
Recenter,
SetMark,
Copy,
CopyWithTheme(String),
Cut,
Paste,
CopyFilePath,
CopyRelativeFilePath,
YankWordForward,
YankWordBackward,
YankToLineEnd,
YankToLineStart,
YankViWordEnd,
AddCursorAbove,
AddCursorBelow,
AddCursorNextMatch,
AddCursorsToLineEnds,
RemoveSecondaryCursors,
Save,
SaveAs,
Open,
SwitchProject,
New,
Close,
CloseTab,
Quit,
ForceQuit,
Detach,
Revert,
ToggleAutoRevert,
FormatBuffer,
TrimTrailingWhitespace,
EnsureFinalNewline,
GotoLine,
ScanLineIndex,
GoToMatchingBracket,
JumpToNextError,
JumpToPreviousError,
SmartHome,
DedentSelection,
ToggleComment,
DabbrevExpand,
ToggleFold,
SetBookmark(char),
JumpToBookmark(char),
ClearBookmark(char),
ListBookmarks,
ToggleSearchCaseSensitive,
ToggleSearchWholeWord,
ToggleSearchRegex,
ToggleSearchConfirmEach,
StartMacroRecording,
StopMacroRecording,
PlayMacro(char),
ToggleMacroRecording(char),
ShowMacro(char),
ListMacros,
PromptRecordMacro,
PromptPlayMacro,
PlayLastMacro,
PromptSetBookmark,
PromptJumpToBookmark,
Undo,
Redo,
ScrollUp,
ScrollDown,
ShowHelp,
ShowKeyboardShortcuts,
ShowWarnings,
ShowStatusLog,
ShowLspStatus,
ShowRemoteIndicatorMenu,
ClearWarnings,
CommandPalette, QuickOpen,
QuickOpenBuffers,
QuickOpenFiles,
OpenLiveGrep,
ResumeLiveGrep,
ToggleUtilityDock,
OpenTerminalInDock,
CycleLiveGrepProvider,
ToggleLineWrap,
ToggleCurrentLineHighlight,
ToggleReadOnly,
TogglePageView,
SetPageWidth,
InspectThemeAtCursor,
SelectTheme,
SelectKeybindingMap,
SelectCursorStyle,
SelectLocale,
NextBuffer,
PrevBuffer,
SwitchToPreviousTab,
SwitchToTabByName,
ScrollTabsLeft,
ScrollTabsRight,
NavigateBack,
NavigateForward,
SplitHorizontal,
SplitVertical,
CloseSplit,
NextSplit,
PrevSplit,
NextWindow,
PrevWindow,
IncreaseSplitSize,
DecreaseSplitSize,
ToggleMaximizeSplit,
PromptConfirm,
PromptConfirmWithText(String),
PromptCancel,
PromptBackspace,
PromptDelete,
PromptMoveLeft,
PromptMoveRight,
PromptMoveStart,
PromptMoveEnd,
PromptSelectPrev,
PromptSelectNext,
PromptPageUp,
PromptPageDown,
PromptAcceptSuggestion,
PromptMoveWordLeft,
PromptMoveWordRight,
PromptDeleteWordForward,
PromptDeleteWordBackward,
PromptDeleteToLineEnd,
PromptCopy,
PromptCut,
PromptPaste,
PromptMoveLeftSelecting,
PromptMoveRightSelecting,
PromptMoveHomeSelecting,
PromptMoveEndSelecting,
PromptSelectWordLeft,
PromptSelectWordRight,
PromptSelectAll,
FileBrowserToggleHidden,
FileBrowserToggleDetectEncoding,
PopupSelectNext,
PopupSelectPrev,
PopupPageUp,
PopupPageDown,
PopupConfirm,
PopupCancel,
PopupFocus,
CompletionAccept,
CompletionDismiss,
ToggleFileExplorer,
ToggleFileExplorerSide,
ToggleMenuBar,
ToggleTabBar,
ToggleStatusBar,
TogglePromptLine,
ToggleVerticalScrollbar,
ToggleHorizontalScrollbar,
FocusFileExplorer,
FocusEditor,
ToggleDockFocus,
FileExplorerUp,
FileExplorerDown,
FileExplorerPageUp,
FileExplorerPageDown,
FileExplorerExpand,
FileExplorerCollapse,
FileExplorerOpen,
FileExplorerRefresh,
FileExplorerNewFile,
FileExplorerNewDirectory,
FileExplorerDelete,
FileExplorerRename,
FileExplorerToggleHidden,
FileExplorerToggleGitignored,
FileExplorerSearchClear,
FileExplorerSearchBackspace,
FileExplorerCopy,
FileExplorerCut,
FileExplorerPaste,
FileExplorerDuplicate,
FileExplorerCopyFullPath,
FileExplorerCopyRelativePath,
FileExplorerExtendSelectionUp,
FileExplorerExtendSelectionDown,
FileExplorerToggleSelect,
FileExplorerSelectAll,
LspCompletion,
LspGotoDefinition,
LspReferences,
LspRename,
LspHover,
LspSignatureHelp,
LspCodeActions,
LspRestart,
LspStop,
LspToggleForBuffer,
ToggleInlayHints,
ToggleMouseHover,
ToggleLineNumbers,
ToggleScrollSync,
ToggleMouseCapture,
ToggleDebugHighlights, SetBackground,
SetBackgroundBlend,
SetTabSize,
SetLineEnding,
SetEncoding,
ReloadWithEncoding,
SetLanguage,
ToggleIndentationStyle,
ToggleTabIndicators,
ToggleWhitespaceIndicators,
ResetBufferSettings,
AddRuler,
RemoveRuler,
DumpConfig,
RedrawScreen,
Search,
FindInSelection,
FindNext,
FindPrevious,
FindSelectionNext, FindSelectionPrevious, Replace,
QueryReplace,
MenuActivate, MenuClose, MenuLeft, MenuRight, MenuUp, MenuDown, MenuExecute, MenuOpen(String),
SwitchKeybindingMap(String),
PluginAction(String),
OpenSettings, CloseSettings, SettingsSave, SettingsReset, SettingsToggleFocus, SettingsActivate, SettingsSearch, SettingsHelp, SettingsIncrement, SettingsDecrement, SettingsInherit,
OpenTerminal, CloseTerminal, FocusTerminal, TerminalEscape, ToggleKeyboardCapture, TerminalPaste,
ShellCommand, ShellCommandReplace,
ToUpperCase, ToLowerCase, ToggleCase, SortLines,
CalibrateInput,
EventDebug,
SuspendProcess,
OpenKeybindingEditor,
LoadPluginFromBuffer,
InitReload, InitEdit, InitCheck,
CompositeNextHunk, CompositePrevHunk,
WorkspaceTrustTrust, WorkspaceTrustRestrict, WorkspaceTrustBlock, WorkspaceTrustPrompt,
None,
}
macro_rules! define_action_str_mapping {
(
$args_name:ident;
simple { $($s_name:literal => $s_variant:ident),* $(,)? }
alias { $($a_name:literal => $a_variant:ident),* $(,)? }
with_char { $($c_name:literal => $c_variant:ident),* $(,)? }
custom { $($x_name:literal => $x_variant:ident : $x_body:expr),* $(,)? }
) => {
pub fn from_str(s: &str, $args_name: &HashMap<String, serde_json::Value>) -> Option<Self> {
Some(match s {
$($s_name => Self::$s_variant,)*
$($a_name => Self::$a_variant,)*
$($c_name => return Self::with_char($args_name, Self::$c_variant),)*
$($x_name => $x_body,)*
_ => Self::PluginAction(s.to_string()),
})
}
pub fn to_action_str(&self) -> String {
match self {
$(Self::$s_variant => $s_name.to_string(),)*
$(Self::$c_variant(_) => $c_name.to_string(),)*
$(Self::$x_variant(_) => $x_name.to_string(),)*
Self::PluginAction(name) => name.clone(),
}
}
pub fn all_action_names() -> Vec<String> {
let mut names = vec![
$($s_name.to_string(),)*
$($a_name.to_string(),)*
$($c_name.to_string(),)*
$($x_name.to_string(),)*
];
names.sort();
names
}
};
}
impl Action {
fn with_char(
args: &HashMap<String, serde_json::Value>,
make_action: impl FnOnce(char) -> Self,
) -> Option<Self> {
if let Some(serde_json::Value::String(value)) = args.get("char") {
value.chars().next().map(make_action)
} else {
None
}
}
define_action_str_mapping! {
args;
simple {
"insert_newline" => InsertNewline,
"insert_tab" => InsertTab,
"move_left" => MoveLeft,
"move_right" => MoveRight,
"move_up" => MoveUp,
"move_down" => MoveDown,
"move_word_left" => MoveWordLeft,
"move_word_right" => MoveWordRight,
"move_word_end" => MoveWordEnd,
"vi_move_word_end" => ViMoveWordEnd,
"move_left_in_line" => MoveLeftInLine,
"move_right_in_line" => MoveRightInLine,
"move_line_start" => MoveLineStart,
"move_line_end" => MoveLineEnd,
"move_line_up" => MoveLineUp,
"move_line_down" => MoveLineDown,
"move_page_up" => MovePageUp,
"move_page_down" => MovePageDown,
"move_document_start" => MoveDocumentStart,
"move_document_end" => MoveDocumentEnd,
"move_to_paragraph_up" => MoveToParagraphUp,
"move_to_paragraph_down" => MoveToParagraphDown,
"select_left" => SelectLeft,
"select_right" => SelectRight,
"select_up" => SelectUp,
"select_down" => SelectDown,
"select_to_paragraph_up" => SelectToParagraphUp,
"select_to_paragraph_down" => SelectToParagraphDown,
"select_word_left" => SelectWordLeft,
"select_word_right" => SelectWordRight,
"select_word_end" => SelectWordEnd,
"vi_select_word_end" => ViSelectWordEnd,
"select_line_start" => SelectLineStart,
"select_line_end" => SelectLineEnd,
"select_document_start" => SelectDocumentStart,
"select_document_end" => SelectDocumentEnd,
"select_page_up" => SelectPageUp,
"select_page_down" => SelectPageDown,
"select_all" => SelectAll,
"select_word" => SelectWord,
"select_line" => SelectLine,
"expand_selection" => ExpandSelection,
"block_select_left" => BlockSelectLeft,
"block_select_right" => BlockSelectRight,
"block_select_up" => BlockSelectUp,
"block_select_down" => BlockSelectDown,
"delete_backward" => DeleteBackward,
"delete_forward" => DeleteForward,
"delete_word_backward" => DeleteWordBackward,
"delete_word_forward" => DeleteWordForward,
"delete_line" => DeleteLine,
"delete_to_line_end" => DeleteToLineEnd,
"delete_to_line_start" => DeleteToLineStart,
"delete_vi_word_end" => DeleteViWordEnd,
"transpose_chars" => TransposeChars,
"open_line" => OpenLine,
"duplicate_line" => DuplicateLine,
"recenter" => Recenter,
"set_mark" => SetMark,
"copy" => Copy,
"cut" => Cut,
"paste" => Paste,
"copy_file_path" => CopyFilePath,
"copy_relative_file_path" => CopyRelativeFilePath,
"yank_word_forward" => YankWordForward,
"yank_word_backward" => YankWordBackward,
"yank_to_line_end" => YankToLineEnd,
"yank_to_line_start" => YankToLineStart,
"yank_vi_word_end" => YankViWordEnd,
"add_cursor_above" => AddCursorAbove,
"add_cursor_below" => AddCursorBelow,
"add_cursor_next_match" => AddCursorNextMatch,
"add_cursors_to_line_ends" => AddCursorsToLineEnds,
"remove_secondary_cursors" => RemoveSecondaryCursors,
"save" => Save,
"save_as" => SaveAs,
"open" => Open,
"switch_project" => SwitchProject,
"new" => New,
"close" => Close,
"close_tab" => CloseTab,
"quit" => Quit,
"force_quit" => ForceQuit,
"detach" => Detach,
"revert" => Revert,
"toggle_auto_revert" => ToggleAutoRevert,
"format_buffer" => FormatBuffer,
"trim_trailing_whitespace" => TrimTrailingWhitespace,
"ensure_final_newline" => EnsureFinalNewline,
"goto_line" => GotoLine,
"scan_line_index" => ScanLineIndex,
"goto_matching_bracket" => GoToMatchingBracket,
"jump_to_next_error" => JumpToNextError,
"jump_to_previous_error" => JumpToPreviousError,
"smart_home" => SmartHome,
"dedent_selection" => DedentSelection,
"toggle_comment" => ToggleComment,
"dabbrev_expand" => DabbrevExpand,
"toggle_fold" => ToggleFold,
"list_bookmarks" => ListBookmarks,
"toggle_search_case_sensitive" => ToggleSearchCaseSensitive,
"toggle_search_whole_word" => ToggleSearchWholeWord,
"toggle_search_regex" => ToggleSearchRegex,
"toggle_search_confirm_each" => ToggleSearchConfirmEach,
"start_macro_recording" => StartMacroRecording,
"stop_macro_recording" => StopMacroRecording,
"list_macros" => ListMacros,
"prompt_record_macro" => PromptRecordMacro,
"prompt_play_macro" => PromptPlayMacro,
"play_last_macro" => PlayLastMacro,
"prompt_set_bookmark" => PromptSetBookmark,
"prompt_jump_to_bookmark" => PromptJumpToBookmark,
"undo" => Undo,
"redo" => Redo,
"scroll_up" => ScrollUp,
"scroll_down" => ScrollDown,
"show_help" => ShowHelp,
"keyboard_shortcuts" => ShowKeyboardShortcuts,
"show_warnings" => ShowWarnings,
"show_status_log" => ShowStatusLog,
"show_lsp_status" => ShowLspStatus,
"show_remote_indicator_menu" => ShowRemoteIndicatorMenu,
"clear_warnings" => ClearWarnings,
"command_palette" => CommandPalette,
"quick_open" => QuickOpen,
"quick_open_buffers" => QuickOpenBuffers,
"quick_open_files" => QuickOpenFiles,
"open_live_grep" => OpenLiveGrep,
"resume_live_grep" => ResumeLiveGrep,
"toggle_utility_dock" => ToggleUtilityDock,
"open_terminal_in_dock" => OpenTerminalInDock,
"cycle_live_grep_provider" => CycleLiveGrepProvider,
"toggle_line_wrap" => ToggleLineWrap,
"toggle_current_line_highlight" => ToggleCurrentLineHighlight,
"toggle_read_only" => ToggleReadOnly,
"toggle_page_view" => TogglePageView,
"set_page_width" => SetPageWidth,
"next_buffer" => NextBuffer,
"prev_buffer" => PrevBuffer,
"switch_to_previous_tab" => SwitchToPreviousTab,
"switch_to_tab_by_name" => SwitchToTabByName,
"scroll_tabs_left" => ScrollTabsLeft,
"scroll_tabs_right" => ScrollTabsRight,
"navigate_back" => NavigateBack,
"navigate_forward" => NavigateForward,
"split_horizontal" => SplitHorizontal,
"split_vertical" => SplitVertical,
"close_split" => CloseSplit,
"next_split" => NextSplit,
"prev_split" => PrevSplit,
"next_window" => NextWindow,
"prev_window" => PrevWindow,
"increase_split_size" => IncreaseSplitSize,
"decrease_split_size" => DecreaseSplitSize,
"toggle_maximize_split" => ToggleMaximizeSplit,
"prompt_confirm" => PromptConfirm,
"prompt_cancel" => PromptCancel,
"prompt_backspace" => PromptBackspace,
"prompt_move_left" => PromptMoveLeft,
"prompt_move_right" => PromptMoveRight,
"prompt_move_start" => PromptMoveStart,
"prompt_move_end" => PromptMoveEnd,
"prompt_select_prev" => PromptSelectPrev,
"prompt_select_next" => PromptSelectNext,
"prompt_page_up" => PromptPageUp,
"prompt_page_down" => PromptPageDown,
"prompt_accept_suggestion" => PromptAcceptSuggestion,
"prompt_delete_word_forward" => PromptDeleteWordForward,
"prompt_delete_word_backward" => PromptDeleteWordBackward,
"prompt_delete_to_line_end" => PromptDeleteToLineEnd,
"prompt_copy" => PromptCopy,
"prompt_cut" => PromptCut,
"prompt_paste" => PromptPaste,
"prompt_move_left_selecting" => PromptMoveLeftSelecting,
"prompt_move_right_selecting" => PromptMoveRightSelecting,
"prompt_move_home_selecting" => PromptMoveHomeSelecting,
"prompt_move_end_selecting" => PromptMoveEndSelecting,
"prompt_select_word_left" => PromptSelectWordLeft,
"prompt_select_word_right" => PromptSelectWordRight,
"prompt_select_all" => PromptSelectAll,
"file_browser_toggle_hidden" => FileBrowserToggleHidden,
"file_browser_toggle_detect_encoding" => FileBrowserToggleDetectEncoding,
"prompt_move_word_left" => PromptMoveWordLeft,
"prompt_move_word_right" => PromptMoveWordRight,
"prompt_delete" => PromptDelete,
"popup_select_next" => PopupSelectNext,
"popup_select_prev" => PopupSelectPrev,
"popup_page_up" => PopupPageUp,
"popup_page_down" => PopupPageDown,
"popup_confirm" => PopupConfirm,
"popup_cancel" => PopupCancel,
"popup_focus" => PopupFocus,
"completion_accept" => CompletionAccept,
"completion_dismiss" => CompletionDismiss,
"toggle_file_explorer" => ToggleFileExplorer,
"toggle_file_explorer_side" => ToggleFileExplorerSide,
"toggle_menu_bar" => ToggleMenuBar,
"toggle_tab_bar" => ToggleTabBar,
"toggle_status_bar" => ToggleStatusBar,
"toggle_prompt_line" => TogglePromptLine,
"toggle_vertical_scrollbar" => ToggleVerticalScrollbar,
"toggle_horizontal_scrollbar" => ToggleHorizontalScrollbar,
"focus_file_explorer" => FocusFileExplorer,
"focus_editor" => FocusEditor,
"toggle_dock_focus" => ToggleDockFocus,
"file_explorer_up" => FileExplorerUp,
"file_explorer_down" => FileExplorerDown,
"file_explorer_page_up" => FileExplorerPageUp,
"file_explorer_page_down" => FileExplorerPageDown,
"file_explorer_expand" => FileExplorerExpand,
"file_explorer_collapse" => FileExplorerCollapse,
"file_explorer_open" => FileExplorerOpen,
"file_explorer_refresh" => FileExplorerRefresh,
"file_explorer_new_file" => FileExplorerNewFile,
"file_explorer_new_directory" => FileExplorerNewDirectory,
"file_explorer_delete" => FileExplorerDelete,
"file_explorer_rename" => FileExplorerRename,
"file_explorer_toggle_hidden" => FileExplorerToggleHidden,
"file_explorer_toggle_gitignored" => FileExplorerToggleGitignored,
"file_explorer_search_clear" => FileExplorerSearchClear,
"file_explorer_search_backspace" => FileExplorerSearchBackspace,
"file_explorer_copy" => FileExplorerCopy,
"file_explorer_cut" => FileExplorerCut,
"file_explorer_paste" => FileExplorerPaste,
"file_explorer_duplicate" => FileExplorerDuplicate,
"file_explorer_copy_full_path" => FileExplorerCopyFullPath,
"file_explorer_copy_relative_path" => FileExplorerCopyRelativePath,
"file_explorer_extend_selection_up" => FileExplorerExtendSelectionUp,
"file_explorer_extend_selection_down" => FileExplorerExtendSelectionDown,
"file_explorer_toggle_select" => FileExplorerToggleSelect,
"file_explorer_select_all" => FileExplorerSelectAll,
"lsp_completion" => LspCompletion,
"lsp_goto_definition" => LspGotoDefinition,
"lsp_references" => LspReferences,
"lsp_rename" => LspRename,
"lsp_hover" => LspHover,
"lsp_signature_help" => LspSignatureHelp,
"lsp_code_actions" => LspCodeActions,
"lsp_restart" => LspRestart,
"lsp_stop" => LspStop,
"lsp_toggle_for_buffer" => LspToggleForBuffer,
"toggle_inlay_hints" => ToggleInlayHints,
"toggle_mouse_hover" => ToggleMouseHover,
"toggle_line_numbers" => ToggleLineNumbers,
"toggle_scroll_sync" => ToggleScrollSync,
"toggle_mouse_capture" => ToggleMouseCapture,
"toggle_debug_highlights" => ToggleDebugHighlights,
"set_background" => SetBackground,
"set_background_blend" => SetBackgroundBlend,
"inspect_theme_at_cursor" => InspectThemeAtCursor,
"select_theme" => SelectTheme,
"select_keybinding_map" => SelectKeybindingMap,
"select_cursor_style" => SelectCursorStyle,
"select_locale" => SelectLocale,
"set_tab_size" => SetTabSize,
"set_line_ending" => SetLineEnding,
"set_encoding" => SetEncoding,
"reload_with_encoding" => ReloadWithEncoding,
"set_language" => SetLanguage,
"toggle_indentation_style" => ToggleIndentationStyle,
"toggle_tab_indicators" => ToggleTabIndicators,
"toggle_whitespace_indicators" => ToggleWhitespaceIndicators,
"reset_buffer_settings" => ResetBufferSettings,
"add_ruler" => AddRuler,
"remove_ruler" => RemoveRuler,
"dump_config" => DumpConfig,
"redraw_screen" => RedrawScreen,
"search" => Search,
"find_in_selection" => FindInSelection,
"find_next" => FindNext,
"find_previous" => FindPrevious,
"find_selection_next" => FindSelectionNext,
"find_selection_previous" => FindSelectionPrevious,
"replace" => Replace,
"query_replace" => QueryReplace,
"menu_activate" => MenuActivate,
"menu_close" => MenuClose,
"menu_left" => MenuLeft,
"menu_right" => MenuRight,
"menu_up" => MenuUp,
"menu_down" => MenuDown,
"menu_execute" => MenuExecute,
"open_terminal" => OpenTerminal,
"close_terminal" => CloseTerminal,
"focus_terminal" => FocusTerminal,
"terminal_escape" => TerminalEscape,
"toggle_keyboard_capture" => ToggleKeyboardCapture,
"terminal_paste" => TerminalPaste,
"shell_command" => ShellCommand,
"shell_command_replace" => ShellCommandReplace,
"to_upper_case" => ToUpperCase,
"to_lower_case" => ToLowerCase,
"toggle_case" => ToggleCase,
"sort_lines" => SortLines,
"calibrate_input" => CalibrateInput,
"event_debug" => EventDebug,
"suspend_process" => SuspendProcess,
"load_plugin_from_buffer" => LoadPluginFromBuffer,
"init_reload" => InitReload,
"init_edit" => InitEdit,
"init_check" => InitCheck,
"open_keybinding_editor" => OpenKeybindingEditor,
"composite_next_hunk" => CompositeNextHunk,
"composite_prev_hunk" => CompositePrevHunk,
"workspace_trust_trust" => WorkspaceTrustTrust,
"workspace_trust_restrict" => WorkspaceTrustRestrict,
"workspace_trust_block" => WorkspaceTrustBlock,
"workspace_trust_prompt" => WorkspaceTrustPrompt,
"noop" => None,
"open_settings" => OpenSettings,
"close_settings" => CloseSettings,
"settings_save" => SettingsSave,
"settings_reset" => SettingsReset,
"settings_toggle_focus" => SettingsToggleFocus,
"settings_activate" => SettingsActivate,
"settings_search" => SettingsSearch,
"settings_help" => SettingsHelp,
"settings_increment" => SettingsIncrement,
"settings_decrement" => SettingsDecrement,
"settings_inherit" => SettingsInherit,
}
alias {
"toggle_compose_mode" => TogglePageView,
"set_compose_width" => SetPageWidth,
"none" => None,
}
with_char {
"insert_char" => InsertChar,
"set_bookmark" => SetBookmark,
"jump_to_bookmark" => JumpToBookmark,
"clear_bookmark" => ClearBookmark,
"play_macro" => PlayMacro,
"toggle_macro_recording" => ToggleMacroRecording,
"show_macro" => ShowMacro,
}
custom {
"copy_with_theme" => CopyWithTheme : {
let theme = args.get("theme").and_then(|v| v.as_str()).unwrap_or("");
Self::CopyWithTheme(theme.to_string())
},
"menu_open" => MenuOpen : {
let name = args.get("name")?.as_str()?;
Self::MenuOpen(name.to_string())
},
"switch_keybinding_map" => SwitchKeybindingMap : {
let map_name = args.get("map")?.as_str()?;
Self::SwitchKeybindingMap(map_name.to_string())
},
"prompt_confirm_with_text" => PromptConfirmWithText : {
let text = args.get("text")?.as_str()?;
Self::PromptConfirmWithText(text.to_string())
},
}
}
pub fn variant_arg_key(bare_action: &str) -> Option<&'static str> {
match bare_action {
"menu_open" => Some("name"),
"switch_keybinding_map" => Some("map"),
_ => None,
}
}
pub fn qualify_action(bare_action: &str, args: &HashMap<String, serde_json::Value>) -> String {
if let Some(key) = Self::variant_arg_key(bare_action) {
if let Some(v) = args.get(key).and_then(|v| v.as_str()) {
return format!("{}:{}", bare_action, v);
}
}
bare_action.to_string()
}
pub fn to_qualified_action_str(&self) -> String {
match self {
Self::MenuOpen(name) => format!("menu_open:{}", name),
Self::SwitchKeybindingMap(map) => format!("switch_keybinding_map:{}", map),
other => other.to_action_str(),
}
}
pub fn unqualify_action(qualified: &str) -> (String, HashMap<String, serde_json::Value>) {
if let Some((bare, suffix)) = qualified.split_once(':') {
if let Some(arg_key) = Self::variant_arg_key(bare) {
let mut args = HashMap::new();
args.insert(
arg_key.to_string(),
serde_json::Value::String(suffix.to_string()),
);
return (bare.to_string(), args);
}
}
(qualified.to_string(), HashMap::new())
}
pub fn is_movement_or_editing(&self) -> bool {
matches!(
self,
Action::MoveLeft
| Action::MoveRight
| Action::MoveUp
| Action::MoveDown
| Action::MoveWordLeft
| Action::MoveWordRight
| Action::MoveWordEnd
| Action::ViMoveWordEnd
| Action::MoveLeftInLine
| Action::MoveRightInLine
| Action::MoveLineStart
| Action::MoveLineEnd
| Action::MovePageUp
| Action::MovePageDown
| Action::MoveDocumentStart
| Action::MoveDocumentEnd
| Action::MoveToParagraphUp
| Action::MoveToParagraphDown
| Action::SelectLeft
| Action::SelectRight
| Action::SelectUp
| Action::SelectDown
| Action::SelectToParagraphUp
| Action::SelectToParagraphDown
| Action::SelectWordLeft
| Action::SelectWordRight
| Action::SelectWordEnd
| Action::ViSelectWordEnd
| Action::SelectLineStart
| Action::SelectLineEnd
| Action::SelectDocumentStart
| Action::SelectDocumentEnd
| Action::SelectPageUp
| Action::SelectPageDown
| Action::SelectAll
| Action::SelectWord
| Action::SelectLine
| Action::ExpandSelection
| Action::BlockSelectLeft
| Action::BlockSelectRight
| Action::BlockSelectUp
| Action::BlockSelectDown
| Action::InsertChar(_)
| Action::InsertNewline
| Action::InsertTab
| Action::DeleteBackward
| Action::DeleteForward
| Action::DeleteWordBackward
| Action::DeleteWordForward
| Action::DeleteLine
| Action::DeleteToLineEnd
| Action::DeleteToLineStart
| Action::TransposeChars
| Action::OpenLine
| Action::DuplicateLine
| Action::MoveLineUp
| Action::MoveLineDown
| Action::Cut
| Action::Paste
| Action::Undo
| Action::Redo
)
}
pub fn is_editing(&self) -> bool {
matches!(
self,
Action::InsertChar(_)
| Action::InsertNewline
| Action::InsertTab
| Action::DeleteBackward
| Action::DeleteForward
| Action::DeleteWordBackward
| Action::DeleteWordForward
| Action::DeleteLine
| Action::DeleteToLineEnd
| Action::DeleteToLineStart
| Action::DeleteViWordEnd
| Action::TransposeChars
| Action::OpenLine
| Action::DuplicateLine
| Action::MoveLineUp
| Action::MoveLineDown
| Action::Cut
| Action::Paste
)
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum ChordResolution {
Complete(Action),
Partial,
NoMatch,
}
#[derive(Clone)]
pub struct KeybindingResolver {
bindings: HashMap<KeyContext, HashMap<(KeyCode, KeyModifiers), Action>>,
default_bindings: HashMap<KeyContext, HashMap<(KeyCode, KeyModifiers), Action>>,
plugin_defaults: HashMap<KeyContext, HashMap<(KeyCode, KeyModifiers), Action>>,
chord_bindings: HashMap<KeyContext, HashMap<Vec<(KeyCode, KeyModifiers)>, Action>>,
default_chord_bindings: HashMap<KeyContext, HashMap<Vec<(KeyCode, KeyModifiers)>, Action>>,
plugin_chord_defaults: HashMap<KeyContext, HashMap<Vec<(KeyCode, KeyModifiers)>, Action>>,
inheriting_modes: std::collections::HashSet<String>,
}
impl KeybindingResolver {
pub fn new(config: &Config) -> Self {
let mut resolver = Self {
bindings: HashMap::new(),
default_bindings: HashMap::new(),
plugin_defaults: HashMap::new(),
chord_bindings: HashMap::new(),
default_chord_bindings: HashMap::new(),
plugin_chord_defaults: HashMap::new(),
inheriting_modes: std::collections::HashSet::new(),
};
let map_bindings = config.resolve_keymap(&config.active_keybinding_map);
resolver.load_default_bindings_from_vec(&map_bindings);
resolver.load_bindings_from_vec(&config.keybindings);
resolver
}
fn load_default_bindings_from_vec(&mut self, bindings: &[crate::config::Keybinding]) {
for binding in bindings {
let context = if let Some(ref when) = binding.when {
KeyContext::from_when_clause(when).unwrap_or(KeyContext::Normal)
} else {
KeyContext::Normal
};
if let Some(action) = Action::from_str(&binding.action, &binding.args) {
if !binding.keys.is_empty() {
let mut sequence = Vec::new();
for key_press in &binding.keys {
if let Some(key_code) = Self::parse_key(&key_press.key) {
let modifiers = Self::parse_modifiers(&key_press.modifiers);
sequence.push((key_code, modifiers));
} else {
break;
}
}
if sequence.len() == binding.keys.len() && !sequence.is_empty() {
self.default_chord_bindings
.entry(context)
.or_default()
.insert(sequence, action);
}
} else if let Some(key_code) = Self::parse_key(&binding.key) {
let modifiers = Self::parse_modifiers(&binding.modifiers);
self.insert_binding_with_equivalents(
context,
key_code,
modifiers,
action,
&binding.key,
);
}
}
}
}
fn insert_binding_with_equivalents(
&mut self,
context: KeyContext,
key_code: KeyCode,
modifiers: KeyModifiers,
action: Action,
key_name: &str,
) {
let context_bindings = self.default_bindings.entry(context.clone()).or_default();
context_bindings.insert((key_code, modifiers), action.clone());
let equivalents = terminal_key_equivalents(key_code, modifiers);
for (equiv_key, equiv_mods) in equivalents {
if let Some(existing_action) = context_bindings.get(&(equiv_key, equiv_mods)) {
if existing_action != &action {
let equiv_name = format!("{:?}", equiv_key);
tracing::warn!(
"Terminal key equivalent conflict in {:?} context: {} (equivalent of {}) \
is bound to {:?}, but {} is bound to {:?}. \
The explicit binding takes precedence.",
context,
equiv_name,
key_name,
existing_action,
key_name,
action
);
}
} else {
context_bindings.insert((equiv_key, equiv_mods), action.clone());
}
}
}
fn load_bindings_from_vec(&mut self, bindings: &[crate::config::Keybinding]) {
for binding in bindings {
let context = if let Some(ref when) = binding.when {
KeyContext::from_when_clause(when).unwrap_or(KeyContext::Normal)
} else {
KeyContext::Normal
};
if let Some(action) = Action::from_str(&binding.action, &binding.args) {
if !binding.keys.is_empty() {
let mut sequence = Vec::new();
for key_press in &binding.keys {
if let Some(key_code) = Self::parse_key(&key_press.key) {
let modifiers = Self::parse_modifiers(&key_press.modifiers);
sequence.push((key_code, modifiers));
} else {
break;
}
}
if sequence.len() == binding.keys.len() && !sequence.is_empty() {
self.chord_bindings
.entry(context)
.or_default()
.insert(sequence, action);
}
} else if let Some(key_code) = Self::parse_key(&binding.key) {
let modifiers = Self::parse_modifiers(&binding.modifiers);
self.bindings
.entry(context)
.or_default()
.insert((key_code, modifiers), action);
}
}
}
}
pub fn load_plugin_default(
&mut self,
context: KeyContext,
key_code: KeyCode,
modifiers: KeyModifiers,
action: Action,
) {
self.plugin_defaults
.entry(context)
.or_default()
.insert((key_code, modifiers), action);
}
pub fn load_plugin_chord_default(
&mut self,
context: KeyContext,
sequence: Vec<(KeyCode, KeyModifiers)>,
action: Action,
) {
self.plugin_chord_defaults
.entry(context)
.or_default()
.insert(sequence, action);
}
pub fn clear_plugin_defaults_for_mode(&mut self, mode_name: &str) {
let context = KeyContext::Mode(mode_name.to_string());
self.plugin_defaults.remove(&context);
self.plugin_chord_defaults.remove(&context);
self.inheriting_modes.remove(mode_name);
}
pub fn set_mode_inherits_normal_bindings(&mut self, mode_name: &str, inherit: bool) {
if inherit {
self.inheriting_modes.insert(mode_name.to_string());
} else {
self.inheriting_modes.remove(mode_name);
}
}
pub fn get_plugin_defaults(
&self,
) -> &HashMap<KeyContext, HashMap<(KeyCode, KeyModifiers), Action>> {
&self.plugin_defaults
}
fn is_application_wide_action(action: &Action) -> bool {
matches!(
action,
Action::Quit
| Action::ForceQuit
| Action::Save
| Action::SaveAs
| Action::ShowHelp
| Action::ShowKeyboardShortcuts
| Action::PromptCancel | Action::PopupCancel )
}
pub fn is_terminal_ui_action(action: &Action) -> bool {
matches!(
action,
Action::CommandPalette
| Action::QuickOpen
| Action::QuickOpenBuffers
| Action::QuickOpenFiles
| Action::OpenLiveGrep
| Action::ResumeLiveGrep
| Action::ToggleUtilityDock
| Action::OpenTerminalInDock
| Action::ToggleDockFocus
| Action::CycleLiveGrepProvider
| Action::OpenSettings
| Action::MenuActivate
| Action::MenuOpen(_)
| Action::ShowHelp
| Action::ShowKeyboardShortcuts
| Action::Quit
| Action::ForceQuit
| Action::NextSplit
| Action::PrevSplit
| Action::NextWindow
| Action::PrevWindow
| Action::SplitHorizontal
| Action::SplitVertical
| Action::CloseSplit
| Action::ToggleMaximizeSplit
| Action::NextBuffer
| Action::PrevBuffer
| Action::Close
| Action::CloseTab
| Action::ScrollTabsLeft
| Action::ScrollTabsRight
| Action::TerminalEscape
| Action::ToggleKeyboardCapture
| Action::OpenTerminal
| Action::CloseTerminal
| Action::TerminalPaste
| Action::ToggleFileExplorer
| Action::ToggleFileExplorerSide
| Action::ToggleMenuBar
)
}
pub fn resolve_chord(
&self,
chord_state: &[(KeyCode, KeyModifiers)],
event: &KeyEvent,
context: KeyContext,
) -> ChordResolution {
let mut full_sequence: Vec<(KeyCode, KeyModifiers)> = chord_state
.iter()
.map(|(c, m)| normalize_key(*c, *m))
.collect();
let (norm_code, norm_mods) = normalize_key(event.code, event.modifiers);
full_sequence.push((norm_code, norm_mods));
tracing::trace!(
"KeybindingResolver.resolve_chord: sequence={:?}, context={:?}",
full_sequence,
context
);
let search_order = vec![
(&self.chord_bindings, &KeyContext::Global, "custom global"),
(
&self.default_chord_bindings,
&KeyContext::Global,
"default global",
),
(&self.chord_bindings, &context, "custom context"),
(&self.default_chord_bindings, &context, "default context"),
(
&self.plugin_chord_defaults,
&context,
"plugin default context",
),
];
let mut has_partial_match = false;
for (binding_map, bind_context, label) in search_order {
if let Some(context_chords) = binding_map.get(bind_context) {
if let Some(action) = context_chords.get(&full_sequence) {
tracing::trace!(" -> Complete chord match in {}: {:?}", label, action);
return ChordResolution::Complete(action.clone());
}
for (chord_seq, _) in context_chords.iter() {
if chord_seq.len() > full_sequence.len()
&& chord_seq[..full_sequence.len()] == full_sequence[..]
{
tracing::trace!(" -> Partial chord match in {}", label);
has_partial_match = true;
break;
}
}
}
}
if has_partial_match {
ChordResolution::Partial
} else {
tracing::trace!(" -> No chord match");
ChordResolution::NoMatch
}
}
pub fn resolve(&self, event: &KeyEvent, context: KeyContext) -> Action {
let (norm_code, norm_mods) = normalize_key(event.code, event.modifiers);
let norm = &(norm_code, norm_mods);
tracing::trace!(
"KeybindingResolver.resolve: code={:?}, modifiers={:?}, context={:?}",
event.code,
event.modifiers,
context
);
if let Some(global_bindings) = self.bindings.get(&KeyContext::Global) {
if let Some(action) = global_bindings.get(norm) {
tracing::trace!(" -> Found in custom global bindings: {:?}", action);
return action.clone();
}
}
if let Some(global_bindings) = self.default_bindings.get(&KeyContext::Global) {
if let Some(action) = global_bindings.get(norm) {
tracing::trace!(" -> Found in default global bindings: {:?}", action);
return action.clone();
}
}
if let Some(context_bindings) = self.bindings.get(&context) {
if let Some(action) = context_bindings.get(norm) {
tracing::trace!(
" -> Found in custom {} bindings: {:?}",
context.to_when_clause(),
action
);
return action.clone();
}
}
if let Some(context_bindings) = self.default_bindings.get(&context) {
if let Some(action) = context_bindings.get(norm) {
tracing::trace!(
" -> Found in default {} bindings: {:?}",
context.to_when_clause(),
action
);
return action.clone();
}
}
if let Some(plugin_bindings) = self.plugin_defaults.get(&context) {
if let Some(action) = plugin_bindings.get(norm) {
tracing::trace!(
" -> Found in plugin default {} bindings: {:?}",
context.to_when_clause(),
action
);
return action.clone();
}
}
if context != KeyContext::Normal {
let full_fallthrough = context.allows_normal_fallthrough()
|| matches!(&context, KeyContext::Mode(name) if self.inheriting_modes.contains(name));
let ui_fallthrough = context.allows_ui_fallthrough();
let custom_normal_has_binding = self
.bindings
.get(&KeyContext::Normal)
.and_then(|m| m.get(norm))
.is_some();
if let Some(normal_bindings) = self.bindings.get(&KeyContext::Normal) {
if let Some(action) = normal_bindings.get(norm) {
if full_fallthrough
|| Self::is_application_wide_action(action)
|| (ui_fallthrough && Self::is_terminal_ui_action(action))
{
tracing::trace!(
" -> Found action in custom normal bindings (fallthrough): {:?}",
action
);
return action.clone();
}
}
}
if !custom_normal_has_binding {
if let Some(normal_bindings) = self.default_bindings.get(&KeyContext::Normal) {
if let Some(action) = normal_bindings.get(norm) {
if full_fallthrough
|| Self::is_application_wide_action(action)
|| (ui_fallthrough && Self::is_terminal_ui_action(action))
{
tracing::trace!(
" -> Found action in default normal bindings (fallthrough): {:?}",
action
);
return action.clone();
}
}
}
}
}
if context.allows_text_input() && is_text_input_modifier(event.modifiers) {
if let KeyCode::Char(c) = event.code {
tracing::trace!(" -> Character input: '{}'", c);
return Action::InsertChar(c);
}
}
tracing::trace!(" -> No binding found, returning Action::None");
Action::None
}
pub fn resolve_in_context_only(&self, event: &KeyEvent, context: KeyContext) -> Option<Action> {
let norm = normalize_key(event.code, event.modifiers);
if let Some(context_bindings) = self.bindings.get(&context) {
if let Some(action) = context_bindings.get(&norm) {
return Some(action.clone());
}
}
if let Some(context_bindings) = self.default_bindings.get(&context) {
if let Some(action) = context_bindings.get(&norm) {
return Some(action.clone());
}
}
None
}
pub fn has_explicit_binding(&self, event: &KeyEvent, context: &KeyContext) -> bool {
let norm = normalize_key(event.code, event.modifiers);
if let Some(bindings) = self.bindings.get(context) {
if bindings.contains_key(&norm) {
return true;
}
}
if let Some(bindings) = self.default_bindings.get(context) {
if bindings.contains_key(&norm) {
return true;
}
}
if let Some(bindings) = self.plugin_defaults.get(context) {
if bindings.contains_key(&norm) {
return true;
}
}
false
}
pub fn resolve_terminal_ui_action(&self, event: &KeyEvent) -> Action {
let norm = normalize_key(event.code, event.modifiers);
tracing::trace!(
"KeybindingResolver.resolve_terminal_ui_action: code={:?}, modifiers={:?}",
event.code,
event.modifiers
);
for bindings in [&self.bindings, &self.default_bindings] {
if let Some(terminal_bindings) = bindings.get(&KeyContext::Terminal) {
if let Some(action) = terminal_bindings.get(&norm) {
if Self::is_terminal_ui_action(action) {
tracing::trace!(" -> Found UI action in terminal bindings: {:?}", action);
return action.clone();
}
}
}
}
for bindings in [&self.bindings, &self.default_bindings] {
if let Some(global_bindings) = bindings.get(&KeyContext::Global) {
if let Some(action) = global_bindings.get(&norm) {
if Self::is_terminal_ui_action(action) {
tracing::trace!(" -> Found UI action in global bindings: {:?}", action);
return action.clone();
}
}
}
}
for bindings in [&self.bindings, &self.default_bindings] {
if let Some(normal_bindings) = bindings.get(&KeyContext::Normal) {
if let Some(action) = normal_bindings.get(&norm) {
if Self::is_terminal_ui_action(action) {
tracing::trace!(" -> Found UI action in normal bindings: {:?}", action);
return action.clone();
}
}
}
}
tracing::trace!(" -> No UI action found");
Action::None
}
pub fn bound_plugin_action_names(&self) -> std::collections::HashSet<String> {
let mut names = std::collections::HashSet::new();
for map in self.bindings.values().chain(self.default_bindings.values()) {
for action in map.values() {
if let Action::PluginAction(name) = action {
names.insert(name.clone());
}
}
}
names
}
pub fn find_keybinding_for_action(
&self,
action_name: &str,
context: KeyContext,
) -> Option<String> {
let target_action = Action::from_str(action_name, &HashMap::new())?;
let search_maps = vec![
self.bindings.get(&context),
self.bindings.get(&KeyContext::Global),
self.default_bindings.get(&context),
self.default_bindings.get(&KeyContext::Global),
];
for map in search_maps.into_iter().flatten() {
let mut matches: Vec<(KeyCode, KeyModifiers)> = map
.iter()
.filter(|(_, action)| {
match (*action, &target_action) {
(Action::PluginAction(a), Action::PluginAction(b)) => a == b,
(a, b) => std::mem::discriminant(a) == std::mem::discriminant(b),
}
})
.map(|((key_code, modifiers), _)| (*key_code, *modifiers))
.collect();
if !matches.is_empty() {
matches.sort_by(|(key_a, mod_a), (key_b, mod_b)| {
let mod_count_a = mod_a.bits().count_ones();
let mod_count_b = mod_b.bits().count_ones();
match mod_count_a.cmp(&mod_count_b) {
std::cmp::Ordering::Equal => {
match mod_a.bits().cmp(&mod_b.bits()) {
std::cmp::Ordering::Equal => {
Self::key_code_sort_key(key_a)
.cmp(&Self::key_code_sort_key(key_b))
}
other => other,
}
}
other => other,
}
});
let (key_code, modifiers) = matches[0];
return Some(format_keybinding(&key_code, &modifiers));
}
}
None
}
fn key_code_sort_key(key_code: &KeyCode) -> (u8, u32) {
match key_code {
KeyCode::Char(c) => (0, *c as u32),
KeyCode::F(n) => (1, *n as u32),
KeyCode::Enter => (2, 0),
KeyCode::Tab => (2, 1),
KeyCode::Backspace => (2, 2),
KeyCode::Delete => (2, 3),
KeyCode::Esc => (2, 4),
KeyCode::Left => (3, 0),
KeyCode::Right => (3, 1),
KeyCode::Up => (3, 2),
KeyCode::Down => (3, 3),
KeyCode::Home => (3, 4),
KeyCode::End => (3, 5),
KeyCode::PageUp => (3, 6),
KeyCode::PageDown => (3, 7),
_ => (255, 0),
}
}
pub fn find_menu_mnemonic(&self, menu_name: &str) -> Option<char> {
let search_maps = vec![
self.bindings.get(&KeyContext::Normal),
self.bindings.get(&KeyContext::Global),
self.default_bindings.get(&KeyContext::Normal),
self.default_bindings.get(&KeyContext::Global),
];
for map in search_maps.into_iter().flatten() {
for ((key_code, modifiers), action) in map {
if let Action::MenuOpen(name) = action {
if name.eq_ignore_ascii_case(menu_name) && *modifiers == KeyModifiers::ALT {
if let KeyCode::Char(c) = key_code {
return Some(c.to_ascii_lowercase());
}
}
}
}
}
None
}
fn parse_key(key: &str) -> Option<KeyCode> {
let lower = key.to_lowercase();
match lower.as_str() {
"enter" => Some(KeyCode::Enter),
"backspace" => Some(KeyCode::Backspace),
"delete" | "del" => Some(KeyCode::Delete),
"tab" => Some(KeyCode::Tab),
"backtab" => Some(KeyCode::BackTab),
"esc" | "escape" => Some(KeyCode::Esc),
"space" => Some(KeyCode::Char(' ')),
"left" => Some(KeyCode::Left),
"right" => Some(KeyCode::Right),
"up" => Some(KeyCode::Up),
"down" => Some(KeyCode::Down),
"home" => Some(KeyCode::Home),
"end" => Some(KeyCode::End),
"pageup" => Some(KeyCode::PageUp),
"pagedown" => Some(KeyCode::PageDown),
s if s.len() == 1 => s.chars().next().map(KeyCode::Char),
s if s.starts_with('f') && s.len() >= 2 => s[1..].parse::<u8>().ok().map(KeyCode::F),
_ => None,
}
}
fn parse_modifiers(modifiers: &[String]) -> KeyModifiers {
let mut result = KeyModifiers::empty();
for m in modifiers {
match m.to_lowercase().as_str() {
"ctrl" | "control" => result |= KeyModifiers::CONTROL,
"shift" => result |= KeyModifiers::SHIFT,
"alt" => result |= KeyModifiers::ALT,
"super" | "cmd" | "command" | "meta" => result |= KeyModifiers::SUPER,
_ => {}
}
}
result
}
pub fn get_all_bindings(&self) -> Vec<(String, String)> {
let mut bindings = Vec::new();
for context in &[
KeyContext::Normal,
KeyContext::Prompt,
KeyContext::Popup,
KeyContext::FileExplorer,
KeyContext::Menu,
KeyContext::CompositeBuffer,
] {
let mut all_keys: HashMap<(KeyCode, KeyModifiers), Action> = HashMap::new();
if let Some(context_defaults) = self.default_bindings.get(context) {
for (key, action) in context_defaults {
all_keys.insert(*key, action.clone());
}
}
if let Some(context_bindings) = self.bindings.get(context) {
for (key, action) in context_bindings {
all_keys.insert(*key, action.clone());
}
}
let context_str = if *context != KeyContext::Normal {
format!("[{}] ", context.to_when_clause())
} else {
String::new()
};
for ((key_code, modifiers), action) in all_keys {
let key_str = Self::format_key(key_code, modifiers);
let action_str = format!("{}{}", context_str, Self::format_action(&action));
bindings.push((key_str, action_str));
}
}
bindings.sort_by(|a, b| a.1.cmp(&b.1));
bindings
}
fn format_key(key_code: KeyCode, modifiers: KeyModifiers) -> String {
format_keybinding(&key_code, &modifiers)
}
pub fn format_action(action: &Action) -> String {
match action {
Action::InsertChar(c) => t!("action.insert_char", char = c),
Action::InsertNewline => t!("action.insert_newline"),
Action::InsertTab => t!("action.insert_tab"),
Action::MoveLeft => t!("action.move_left"),
Action::MoveRight => t!("action.move_right"),
Action::MoveUp => t!("action.move_up"),
Action::MoveDown => t!("action.move_down"),
Action::MoveWordLeft => t!("action.move_word_left"),
Action::MoveWordRight => t!("action.move_word_right"),
Action::MoveWordEnd => t!("action.move_word_end"),
Action::ViMoveWordEnd => t!("action.move_word_end"),
Action::MoveLeftInLine => t!("action.move_left"),
Action::MoveRightInLine => t!("action.move_right"),
Action::MoveLineStart => t!("action.move_line_start"),
Action::MoveLineEnd => t!("action.move_line_end"),
Action::MoveLineUp => t!("action.move_line_up"),
Action::MoveLineDown => t!("action.move_line_down"),
Action::MovePageUp => t!("action.move_page_up"),
Action::MovePageDown => t!("action.move_page_down"),
Action::MoveDocumentStart => t!("action.move_document_start"),
Action::MoveDocumentEnd => t!("action.move_document_end"),
Action::SelectLeft => t!("action.select_left"),
Action::SelectRight => t!("action.select_right"),
Action::SelectUp => t!("action.select_up"),
Action::SelectDown => t!("action.select_down"),
Action::SelectToParagraphUp => t!("action.select_to_paragraph_up"),
Action::SelectToParagraphDown => t!("action.select_to_paragraph_down"),
Action::MoveToParagraphUp => t!("action.move_to_paragraph_up"),
Action::MoveToParagraphDown => t!("action.move_to_paragraph_down"),
Action::SelectWordLeft => t!("action.select_word_left"),
Action::SelectWordRight => t!("action.select_word_right"),
Action::SelectWordEnd => t!("action.select_word_end"),
Action::ViSelectWordEnd => t!("action.select_word_end"),
Action::SelectLineStart => t!("action.select_line_start"),
Action::SelectLineEnd => t!("action.select_line_end"),
Action::SelectDocumentStart => t!("action.select_document_start"),
Action::SelectDocumentEnd => t!("action.select_document_end"),
Action::SelectPageUp => t!("action.select_page_up"),
Action::SelectPageDown => t!("action.select_page_down"),
Action::SelectAll => t!("action.select_all"),
Action::SelectWord => t!("action.select_word"),
Action::SelectLine => t!("action.select_line"),
Action::ExpandSelection => t!("action.expand_selection"),
Action::BlockSelectLeft => t!("action.block_select_left"),
Action::BlockSelectRight => t!("action.block_select_right"),
Action::BlockSelectUp => t!("action.block_select_up"),
Action::BlockSelectDown => t!("action.block_select_down"),
Action::DeleteBackward => t!("action.delete_backward"),
Action::DeleteForward => t!("action.delete_forward"),
Action::DeleteWordBackward => t!("action.delete_word_backward"),
Action::DeleteWordForward => t!("action.delete_word_forward"),
Action::DeleteLine => t!("action.delete_line"),
Action::DeleteToLineEnd => t!("action.delete_to_line_end"),
Action::DeleteToLineStart => t!("action.delete_to_line_start"),
Action::DeleteViWordEnd => t!("action.delete_word_forward"),
Action::TransposeChars => t!("action.transpose_chars"),
Action::OpenLine => t!("action.open_line"),
Action::DuplicateLine => t!("action.duplicate_line"),
Action::Recenter => t!("action.recenter"),
Action::SetMark => t!("action.set_mark"),
Action::Copy => t!("action.copy"),
Action::CopyWithTheme(theme) if theme.is_empty() => t!("action.copy_with_formatting"),
Action::CopyWithTheme(theme) => t!("action.copy_with_theme", theme = theme),
Action::Cut => t!("action.cut"),
Action::Paste => t!("action.paste"),
Action::CopyFilePath => t!("action.copy_file_path"),
Action::CopyRelativeFilePath => t!("action.copy_relative_file_path"),
Action::YankWordForward => t!("action.yank_word_forward"),
Action::YankWordBackward => t!("action.yank_word_backward"),
Action::YankToLineEnd => t!("action.yank_to_line_end"),
Action::YankToLineStart => t!("action.yank_to_line_start"),
Action::YankViWordEnd => t!("action.yank_word_forward"),
Action::AddCursorAbove => t!("action.add_cursor_above"),
Action::AddCursorBelow => t!("action.add_cursor_below"),
Action::AddCursorNextMatch => t!("action.add_cursor_next_match"),
Action::AddCursorsToLineEnds => t!("action.add_cursors_to_line_ends"),
Action::RemoveSecondaryCursors => t!("action.remove_secondary_cursors"),
Action::Save => t!("action.save"),
Action::SaveAs => t!("action.save_as"),
Action::Open => t!("action.open"),
Action::SwitchProject => t!("action.switch_project"),
Action::New => t!("action.new"),
Action::Close => t!("action.close"),
Action::CloseTab => t!("action.close_tab"),
Action::Quit => t!("action.quit"),
Action::ForceQuit => t!("action.force_quit"),
Action::Detach => t!("action.detach"),
Action::Revert => t!("action.revert"),
Action::ToggleAutoRevert => t!("action.toggle_auto_revert"),
Action::FormatBuffer => t!("action.format_buffer"),
Action::TrimTrailingWhitespace => t!("action.trim_trailing_whitespace"),
Action::EnsureFinalNewline => t!("action.ensure_final_newline"),
Action::GotoLine => t!("action.goto_line"),
Action::ScanLineIndex => t!("action.scan_line_index"),
Action::GoToMatchingBracket => t!("action.goto_matching_bracket"),
Action::JumpToNextError => t!("action.jump_to_next_error"),
Action::JumpToPreviousError => t!("action.jump_to_previous_error"),
Action::SmartHome => t!("action.smart_home"),
Action::DedentSelection => t!("action.dedent_selection"),
Action::ToggleComment => t!("action.toggle_comment"),
Action::DabbrevExpand => std::borrow::Cow::Borrowed("Expand abbreviation (dabbrev)"),
Action::ToggleFold => t!("action.toggle_fold"),
Action::SetBookmark(c) => t!("action.set_bookmark", key = c),
Action::JumpToBookmark(c) => t!("action.jump_to_bookmark", key = c),
Action::ClearBookmark(c) => t!("action.clear_bookmark", key = c),
Action::ListBookmarks => t!("action.list_bookmarks"),
Action::ToggleSearchCaseSensitive => t!("action.toggle_search_case_sensitive"),
Action::ToggleSearchWholeWord => t!("action.toggle_search_whole_word"),
Action::ToggleSearchRegex => t!("action.toggle_search_regex"),
Action::ToggleSearchConfirmEach => t!("action.toggle_search_confirm_each"),
Action::StartMacroRecording => t!("action.start_macro_recording"),
Action::StopMacroRecording => t!("action.stop_macro_recording"),
Action::PlayMacro(c) => t!("action.play_macro", key = c),
Action::ToggleMacroRecording(c) => t!("action.toggle_macro_recording", key = c),
Action::ShowMacro(c) => t!("action.show_macro", key = c),
Action::ListMacros => t!("action.list_macros"),
Action::PromptRecordMacro => t!("action.prompt_record_macro"),
Action::PromptPlayMacro => t!("action.prompt_play_macro"),
Action::PlayLastMacro => t!("action.play_last_macro"),
Action::PromptSetBookmark => t!("action.prompt_set_bookmark"),
Action::PromptJumpToBookmark => t!("action.prompt_jump_to_bookmark"),
Action::Undo => t!("action.undo"),
Action::Redo => t!("action.redo"),
Action::ScrollUp => t!("action.scroll_up"),
Action::ScrollDown => t!("action.scroll_down"),
Action::ShowHelp => t!("action.show_help"),
Action::ShowKeyboardShortcuts => t!("action.show_keyboard_shortcuts"),
Action::ShowWarnings => t!("action.show_warnings"),
Action::ShowStatusLog => t!("action.show_status_log"),
Action::ShowLspStatus => t!("action.show_lsp_status"),
Action::ShowRemoteIndicatorMenu => t!("action.show_remote_indicator_menu"),
Action::ClearWarnings => t!("action.clear_warnings"),
Action::CommandPalette => t!("action.command_palette"),
Action::QuickOpen => t!("action.quick_open"),
Action::QuickOpenBuffers => t!("action.quick_open_buffers"),
Action::QuickOpenFiles => t!("action.quick_open_files"),
Action::OpenLiveGrep => t!("action.open_live_grep"),
Action::ResumeLiveGrep => t!("action.resume_live_grep"),
Action::ToggleUtilityDock => t!("action.toggle_utility_dock"),
Action::OpenTerminalInDock => t!("action.open_terminal_in_dock"),
Action::CycleLiveGrepProvider => t!("action.cycle_live_grep_provider"),
Action::InspectThemeAtCursor => t!("action.inspect_theme_at_cursor"),
Action::ToggleLineWrap => t!("action.toggle_line_wrap"),
Action::ToggleCurrentLineHighlight => t!("action.toggle_current_line_highlight"),
Action::ToggleReadOnly => t!("action.toggle_read_only"),
Action::TogglePageView => t!("action.toggle_page_view"),
Action::SetPageWidth => t!("action.set_page_width"),
Action::NextBuffer => t!("action.next_buffer"),
Action::PrevBuffer => t!("action.prev_buffer"),
Action::NavigateBack => t!("action.navigate_back"),
Action::NavigateForward => t!("action.navigate_forward"),
Action::SplitHorizontal => t!("action.split_horizontal"),
Action::SplitVertical => t!("action.split_vertical"),
Action::CloseSplit => t!("action.close_split"),
Action::NextSplit => t!("action.next_split"),
Action::PrevSplit => t!("action.prev_split"),
Action::NextWindow => t!("action.next_window"),
Action::PrevWindow => t!("action.prev_window"),
Action::IncreaseSplitSize => t!("action.increase_split_size"),
Action::DecreaseSplitSize => t!("action.decrease_split_size"),
Action::ToggleMaximizeSplit => t!("action.toggle_maximize_split"),
Action::PromptConfirm => t!("action.prompt_confirm"),
Action::PromptConfirmWithText(ref text) => {
format!("{} ({})", t!("action.prompt_confirm"), text).into()
}
Action::PromptCancel => t!("action.prompt_cancel"),
Action::PromptBackspace => t!("action.prompt_backspace"),
Action::PromptDelete => t!("action.prompt_delete"),
Action::PromptMoveLeft => t!("action.prompt_move_left"),
Action::PromptMoveRight => t!("action.prompt_move_right"),
Action::PromptMoveStart => t!("action.prompt_move_start"),
Action::PromptMoveEnd => t!("action.prompt_move_end"),
Action::PromptSelectPrev => t!("action.prompt_select_prev"),
Action::PromptSelectNext => t!("action.prompt_select_next"),
Action::PromptPageUp => t!("action.prompt_page_up"),
Action::PromptPageDown => t!("action.prompt_page_down"),
Action::PromptAcceptSuggestion => t!("action.prompt_accept_suggestion"),
Action::PromptMoveWordLeft => t!("action.prompt_move_word_left"),
Action::PromptMoveWordRight => t!("action.prompt_move_word_right"),
Action::PromptDeleteWordForward => t!("action.prompt_delete_word_forward"),
Action::PromptDeleteWordBackward => t!("action.prompt_delete_word_backward"),
Action::PromptDeleteToLineEnd => t!("action.prompt_delete_to_line_end"),
Action::PromptCopy => t!("action.prompt_copy"),
Action::PromptCut => t!("action.prompt_cut"),
Action::PromptPaste => t!("action.prompt_paste"),
Action::PromptMoveLeftSelecting => t!("action.prompt_move_left_selecting"),
Action::PromptMoveRightSelecting => t!("action.prompt_move_right_selecting"),
Action::PromptMoveHomeSelecting => t!("action.prompt_move_home_selecting"),
Action::PromptMoveEndSelecting => t!("action.prompt_move_end_selecting"),
Action::PromptSelectWordLeft => t!("action.prompt_select_word_left"),
Action::PromptSelectWordRight => t!("action.prompt_select_word_right"),
Action::PromptSelectAll => t!("action.prompt_select_all"),
Action::FileBrowserToggleHidden => t!("action.file_browser_toggle_hidden"),
Action::FileBrowserToggleDetectEncoding => {
t!("action.file_browser_toggle_detect_encoding")
}
Action::PopupSelectNext => t!("action.popup_select_next"),
Action::PopupSelectPrev => t!("action.popup_select_prev"),
Action::PopupPageUp => t!("action.popup_page_up"),
Action::PopupPageDown => t!("action.popup_page_down"),
Action::PopupConfirm => t!("action.popup_confirm"),
Action::PopupCancel => t!("action.popup_cancel"),
Action::PopupFocus => t!("action.popup_focus"),
Action::CompletionAccept => t!("action.completion_accept"),
Action::CompletionDismiss => t!("action.completion_dismiss"),
Action::ToggleFileExplorer => t!("action.toggle_file_explorer"),
Action::ToggleFileExplorerSide => t!("action.toggle_file_explorer_side"),
Action::ToggleMenuBar => t!("action.toggle_menu_bar"),
Action::ToggleTabBar => t!("action.toggle_tab_bar"),
Action::ToggleStatusBar => t!("action.toggle_status_bar"),
Action::TogglePromptLine => t!("action.toggle_prompt_line"),
Action::ToggleVerticalScrollbar => t!("action.toggle_vertical_scrollbar"),
Action::ToggleHorizontalScrollbar => t!("action.toggle_horizontal_scrollbar"),
Action::FocusFileExplorer => t!("action.focus_file_explorer"),
Action::FocusEditor => t!("action.focus_editor"),
Action::ToggleDockFocus => t!("action.toggle_dock_focus"),
Action::FileExplorerUp => t!("action.file_explorer_up"),
Action::FileExplorerDown => t!("action.file_explorer_down"),
Action::FileExplorerPageUp => t!("action.file_explorer_page_up"),
Action::FileExplorerPageDown => t!("action.file_explorer_page_down"),
Action::FileExplorerExpand => t!("action.file_explorer_expand"),
Action::FileExplorerCollapse => t!("action.file_explorer_collapse"),
Action::FileExplorerOpen => t!("action.file_explorer_open"),
Action::FileExplorerRefresh => t!("action.file_explorer_refresh"),
Action::FileExplorerNewFile => t!("action.file_explorer_new_file"),
Action::FileExplorerNewDirectory => t!("action.file_explorer_new_directory"),
Action::FileExplorerDelete => t!("action.file_explorer_delete"),
Action::FileExplorerRename => t!("action.file_explorer_rename"),
Action::FileExplorerToggleHidden => t!("action.file_explorer_toggle_hidden"),
Action::FileExplorerToggleGitignored => t!("action.file_explorer_toggle_gitignored"),
Action::FileExplorerSearchClear => t!("action.file_explorer_search_clear"),
Action::FileExplorerSearchBackspace => t!("action.file_explorer_search_backspace"),
Action::FileExplorerCopy => t!("action.file_explorer_copy"),
Action::FileExplorerCut => t!("action.file_explorer_cut"),
Action::FileExplorerPaste => t!("action.file_explorer_paste"),
Action::FileExplorerDuplicate => t!("action.file_explorer_duplicate"),
Action::FileExplorerCopyFullPath => t!("action.file_explorer_copy_full_path"),
Action::FileExplorerCopyRelativePath => t!("action.file_explorer_copy_relative_path"),
Action::FileExplorerExtendSelectionUp => t!("action.file_explorer_extend_selection_up"),
Action::FileExplorerExtendSelectionDown => {
t!("action.file_explorer_extend_selection_down")
}
Action::FileExplorerToggleSelect => t!("action.file_explorer_toggle_select"),
Action::FileExplorerSelectAll => t!("action.file_explorer_select_all"),
Action::LspCompletion => t!("action.lsp_completion"),
Action::LspGotoDefinition => t!("action.lsp_goto_definition"),
Action::LspReferences => t!("action.lsp_references"),
Action::LspRename => t!("action.lsp_rename"),
Action::LspHover => t!("action.lsp_hover"),
Action::LspSignatureHelp => t!("action.lsp_signature_help"),
Action::LspCodeActions => t!("action.lsp_code_actions"),
Action::LspRestart => t!("action.lsp_restart"),
Action::LspStop => t!("action.lsp_stop"),
Action::LspToggleForBuffer => t!("action.lsp_toggle_for_buffer"),
Action::ToggleInlayHints => t!("action.toggle_inlay_hints"),
Action::ToggleMouseHover => t!("action.toggle_mouse_hover"),
Action::ToggleLineNumbers => t!("action.toggle_line_numbers"),
Action::ToggleScrollSync => t!("action.toggle_scroll_sync"),
Action::ToggleMouseCapture => t!("action.toggle_mouse_capture"),
Action::ToggleDebugHighlights => t!("action.toggle_debug_highlights"),
Action::SetBackground => t!("action.set_background"),
Action::SetBackgroundBlend => t!("action.set_background_blend"),
Action::AddRuler => t!("action.add_ruler"),
Action::RemoveRuler => t!("action.remove_ruler"),
Action::SetTabSize => t!("action.set_tab_size"),
Action::SetLineEnding => t!("action.set_line_ending"),
Action::SetEncoding => t!("action.set_encoding"),
Action::ReloadWithEncoding => t!("action.reload_with_encoding"),
Action::SetLanguage => t!("action.set_language"),
Action::ToggleIndentationStyle => t!("action.toggle_indentation_style"),
Action::ToggleTabIndicators => t!("action.toggle_tab_indicators"),
Action::ToggleWhitespaceIndicators => t!("action.toggle_whitespace_indicators"),
Action::ResetBufferSettings => t!("action.reset_buffer_settings"),
Action::DumpConfig => t!("action.dump_config"),
Action::RedrawScreen => t!("action.redraw_screen"),
Action::Search => t!("action.search"),
Action::FindInSelection => t!("action.find_in_selection"),
Action::FindNext => t!("action.find_next"),
Action::FindPrevious => t!("action.find_previous"),
Action::FindSelectionNext => t!("action.find_selection_next"),
Action::FindSelectionPrevious => t!("action.find_selection_previous"),
Action::Replace => t!("action.replace"),
Action::QueryReplace => t!("action.query_replace"),
Action::MenuActivate => t!("action.menu_activate"),
Action::MenuClose => t!("action.menu_close"),
Action::MenuLeft => t!("action.menu_left"),
Action::MenuRight => t!("action.menu_right"),
Action::MenuUp => t!("action.menu_up"),
Action::MenuDown => t!("action.menu_down"),
Action::MenuExecute => t!("action.menu_execute"),
Action::MenuOpen(name) => t!("action.menu_open", name = name),
Action::SwitchKeybindingMap(map) => t!("action.switch_keybinding_map", map = map),
Action::PluginAction(name) => t!("action.plugin_action", name = name),
Action::ScrollTabsLeft => t!("action.scroll_tabs_left"),
Action::ScrollTabsRight => t!("action.scroll_tabs_right"),
Action::SelectTheme => t!("action.select_theme"),
Action::SelectKeybindingMap => t!("action.select_keybinding_map"),
Action::SelectCursorStyle => t!("action.select_cursor_style"),
Action::SelectLocale => t!("action.select_locale"),
Action::SwitchToPreviousTab => t!("action.switch_to_previous_tab"),
Action::SwitchToTabByName => t!("action.switch_to_tab_by_name"),
Action::OpenTerminal => t!("action.open_terminal"),
Action::CloseTerminal => t!("action.close_terminal"),
Action::FocusTerminal => t!("action.focus_terminal"),
Action::TerminalEscape => t!("action.terminal_escape"),
Action::ToggleKeyboardCapture => t!("action.toggle_keyboard_capture"),
Action::TerminalPaste => t!("action.terminal_paste"),
Action::OpenSettings => t!("action.open_settings"),
Action::CloseSettings => t!("action.close_settings"),
Action::SettingsSave => t!("action.settings_save"),
Action::SettingsReset => t!("action.settings_reset"),
Action::SettingsToggleFocus => t!("action.settings_toggle_focus"),
Action::SettingsActivate => t!("action.settings_activate"),
Action::SettingsSearch => t!("action.settings_search"),
Action::SettingsHelp => t!("action.settings_help"),
Action::SettingsIncrement => t!("action.settings_increment"),
Action::SettingsDecrement => t!("action.settings_decrement"),
Action::SettingsInherit => t!("action.settings_inherit"),
Action::ShellCommand => t!("action.shell_command"),
Action::ShellCommandReplace => t!("action.shell_command_replace"),
Action::ToUpperCase => t!("action.to_uppercase"),
Action::ToLowerCase => t!("action.to_lowercase"),
Action::ToggleCase => t!("action.to_uppercase"),
Action::SortLines => t!("action.sort_lines"),
Action::CalibrateInput => t!("action.calibrate_input"),
Action::EventDebug => t!("action.event_debug"),
Action::SuspendProcess => t!("action.suspend_process"),
Action::LoadPluginFromBuffer => "Load Plugin from Buffer".into(),
Action::InitReload => "Reload init.ts".into(),
Action::InitEdit => "Edit init.ts".into(),
Action::InitCheck => "Check init.ts".into(),
Action::OpenKeybindingEditor => "Keybinding Editor".into(),
Action::CompositeNextHunk => t!("action.composite_next_hunk"),
Action::CompositePrevHunk => t!("action.composite_prev_hunk"),
Action::WorkspaceTrustTrust => t!("action.workspace_trust_trust"),
Action::WorkspaceTrustRestrict => t!("action.workspace_trust_restrict"),
Action::WorkspaceTrustBlock => t!("action.workspace_trust_block"),
Action::WorkspaceTrustPrompt => t!("action.workspace_trust_prompt"),
Action::None => t!("action.none"),
}
.to_string()
}
pub fn parse_key_public(key: &str) -> Option<KeyCode> {
Self::parse_key(key)
}
pub fn parse_modifiers_public(modifiers: &[String]) -> KeyModifiers {
Self::parse_modifiers(modifiers)
}
pub fn format_action_from_str(action_name: &str) -> String {
Self::format_action_from_str_with_args(action_name, &std::collections::HashMap::new())
}
pub fn format_action_from_str_with_args(
action_name: &str,
args: &std::collections::HashMap<String, serde_json::Value>,
) -> String {
if let Some(action) = Action::from_str(action_name, args) {
Self::format_action(&action)
} else {
action_name
.split('_')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
Some(c) => {
let upper: String = c.to_uppercase().collect();
format!("{}{}", upper, chars.as_str())
}
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
}
pub fn all_action_names() -> Vec<String> {
Action::all_action_names()
}
pub fn get_keybinding_for_action(
&self,
action: &Action,
context: KeyContext,
) -> Option<String> {
self.get_keybinding_event_for_action(action, context)
.map(|(k, m)| format_keybinding(&k, &m))
}
pub fn get_keybinding_event_for_action(
&self,
action: &Action,
context: KeyContext,
) -> Option<(KeyCode, KeyModifiers)> {
fn find_best_keybinding(
bindings: &HashMap<(KeyCode, KeyModifiers), Action>,
action: &Action,
) -> Option<(KeyCode, KeyModifiers)> {
let matches: Vec<_> = bindings
.iter()
.filter(|(_, a)| *a == action)
.map(|((k, m), _)| (*k, *m))
.collect();
if matches.is_empty() {
return None;
}
let mut sorted = matches;
sorted.sort_by(|(k1, m1), (k2, m2)| {
let score1 = keybinding_priority_score(k1);
let score2 = keybinding_priority_score(k2);
match score1.cmp(&score2) {
std::cmp::Ordering::Equal => {
let s1 = format_keybinding(k1, m1);
let s2 = format_keybinding(k2, m2);
s1.cmp(&s2)
}
other => other,
}
});
sorted.into_iter().next()
}
if let Some(context_bindings) = self.bindings.get(&context) {
if let Some(hit) = find_best_keybinding(context_bindings, action) {
return Some(hit);
}
}
if let Some(context_bindings) = self.default_bindings.get(&context) {
if let Some(hit) = find_best_keybinding(context_bindings, action) {
return Some(hit);
}
}
if context != KeyContext::Normal
&& (context.allows_normal_fallthrough()
|| Self::is_application_wide_action(action)
|| (context.allows_ui_fallthrough() && Self::is_terminal_ui_action(action)))
{
if let Some(normal_bindings) = self.bindings.get(&KeyContext::Normal) {
if let Some(hit) = find_best_keybinding(normal_bindings, action) {
return Some(hit);
}
}
if let Some(normal_bindings) = self.default_bindings.get(&KeyContext::Normal) {
if let Some(hit) = find_best_keybinding(normal_bindings, action) {
return Some(hit);
}
}
}
None
}
pub fn reload(&mut self, config: &Config) {
self.bindings.clear();
for binding in &config.keybindings {
if let Some(key_code) = Self::parse_key(&binding.key) {
let modifiers = Self::parse_modifiers(&binding.modifiers);
if let Some(action) = Action::from_str(&binding.action, &binding.args) {
let context = if let Some(ref when) = binding.when {
KeyContext::from_when_clause(when).unwrap_or(KeyContext::Normal)
} else {
KeyContext::Normal
};
self.bindings
.entry(context)
.or_default()
.insert((key_code, modifiers), action);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_key() {
assert_eq!(KeybindingResolver::parse_key("enter"), Some(KeyCode::Enter));
assert_eq!(
KeybindingResolver::parse_key("backspace"),
Some(KeyCode::Backspace)
);
assert_eq!(KeybindingResolver::parse_key("tab"), Some(KeyCode::Tab));
assert_eq!(
KeybindingResolver::parse_key("backtab"),
Some(KeyCode::BackTab)
);
assert_eq!(
KeybindingResolver::parse_key("BackTab"),
Some(KeyCode::BackTab)
);
assert_eq!(KeybindingResolver::parse_key("a"), Some(KeyCode::Char('a')));
}
#[test]
fn test_parse_modifiers() {
let mods = vec!["ctrl".to_string()];
assert_eq!(
KeybindingResolver::parse_modifiers(&mods),
KeyModifiers::CONTROL
);
let mods = vec!["ctrl".to_string(), "shift".to_string()];
assert_eq!(
KeybindingResolver::parse_modifiers(&mods),
KeyModifiers::CONTROL | KeyModifiers::SHIFT
);
}
#[test]
fn test_format_action_from_str_distinguishes_menu_open_by_name() {
let mut file_args = HashMap::new();
file_args.insert(
"name".to_string(),
serde_json::Value::String("File".to_string()),
);
let mut edit_args = HashMap::new();
edit_args.insert(
"name".to_string(),
serde_json::Value::String("Edit".to_string()),
);
let file_display =
KeybindingResolver::format_action_from_str_with_args("menu_open", &file_args);
let edit_display =
KeybindingResolver::format_action_from_str_with_args("menu_open", &edit_args);
let no_args_display = KeybindingResolver::format_action_from_str("menu_open");
assert_ne!(
file_display, edit_display,
"menu_open with different names should produce different descriptions"
);
assert!(
file_display.contains("File"),
"expected the File menu description to contain \"File\", got {file_display:?}"
);
assert!(
edit_display.contains("Edit"),
"expected the Edit menu description to contain \"Edit\", got {edit_display:?}"
);
assert_eq!(no_args_display, "Menu Open");
}
#[test]
fn test_format_action_word_end_actions_are_localized() {
crate::i18n::set_locale("en");
let move_desc = KeybindingResolver::format_action(&Action::MoveWordEnd);
assert_ne!(
move_desc, "action.move_word_end",
"MoveWordEnd should resolve to a translated description"
);
let select_desc = KeybindingResolver::format_action(&Action::SelectWordEnd);
assert_ne!(
select_desc, "action.select_word_end",
"SelectWordEnd should resolve to a translated description"
);
assert_eq!(
KeybindingResolver::format_action(&Action::ViMoveWordEnd),
move_desc,
);
assert_eq!(
KeybindingResolver::format_action(&Action::ViSelectWordEnd),
select_desc,
);
}
#[test]
fn test_qualify_and_unqualify_roundtrip_menu_open() {
let mut args = HashMap::new();
args.insert(
"name".to_string(),
serde_json::Value::String("File".to_string()),
);
let qualified = Action::qualify_action("menu_open", &args);
assert_eq!(qualified, "menu_open:File");
let (bare, parsed_args) = Action::unqualify_action(&qualified);
assert_eq!(bare, "menu_open");
assert_eq!(
parsed_args.get("name").and_then(|v| v.as_str()),
Some("File")
);
}
#[test]
fn test_qualify_action_passthrough_for_unparameterised() {
let args = HashMap::new();
assert_eq!(Action::qualify_action("save", &args), "save");
let (bare, parsed) = Action::unqualify_action("save");
assert_eq!(bare, "save");
assert!(parsed.is_empty());
}
#[test]
fn test_qualify_action_no_suffix_when_arg_missing() {
let args = HashMap::new();
assert_eq!(Action::qualify_action("menu_open", &args), "menu_open");
}
#[test]
fn test_unqualify_action_ignores_colon_on_unknown_action() {
let (bare, parsed) = Action::unqualify_action("my_plugin:action_with:colons");
assert_eq!(bare, "my_plugin:action_with:colons");
assert!(parsed.is_empty());
}
#[test]
fn test_to_qualified_action_str_for_menu_open() {
let action = Action::MenuOpen("Edit".to_string());
assert_eq!(action.to_qualified_action_str(), "menu_open:Edit");
}
#[test]
fn test_resolve_basic() {
let config = Config::default();
let resolver = KeybindingResolver::new(&config);
let event = KeyEvent::new(KeyCode::Left, KeyModifiers::empty());
assert_eq!(
resolver.resolve(&event, KeyContext::Normal),
Action::MoveLeft
);
let event = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty());
assert_eq!(
resolver.resolve(&event, KeyContext::Normal),
Action::InsertChar('a')
);
}
#[test]
fn test_panel_mode_passthrough_for_ui_actions() {
let config = Config::default();
let resolver = KeybindingResolver::new(&config);
let mode_ctx = KeyContext::Mode("search-replace-list".to_string());
let alt_close = KeyEvent::new(KeyCode::Char(']'), KeyModifiers::ALT);
assert_eq!(
resolver.resolve(&alt_close, mode_ctx.clone()),
Action::NextSplit,
"Alt+] should fall through to next_split inside a panel mode"
);
let alt_open = KeyEvent::new(KeyCode::Char('['), KeyModifiers::ALT);
assert_eq!(
resolver.resolve(&alt_open, mode_ctx.clone()),
Action::PrevSplit,
"Alt+[ should fall through to prev_split inside a panel mode"
);
let ctrl_s = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL);
assert_eq!(
resolver.resolve(&ctrl_s, mode_ctx.clone()),
Action::Save,
"Ctrl+S should still save while a panel mode is active"
);
let ctrl_d = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL);
assert_ne!(
resolver.resolve(&ctrl_d, mode_ctx),
Action::AddCursorNextMatch,
"Ctrl+D (add cursor next match) must not pass through to a panel mode"
);
}
#[test]
fn test_shift_backspace_matches_backspace() {
let config = Config::default();
let resolver = KeybindingResolver::new(&config);
let backspace = KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty());
let shift_backspace = KeyEvent::new(KeyCode::Backspace, KeyModifiers::SHIFT);
assert_eq!(
resolver.resolve(&backspace, KeyContext::Normal),
Action::DeleteBackward,
"Backspace should resolve to DeleteBackward in Normal context"
);
assert_eq!(
resolver.resolve(&shift_backspace, KeyContext::Normal),
Action::DeleteBackward,
"Shift+Backspace should resolve to DeleteBackward (same as Backspace) in Normal context"
);
assert_eq!(
resolver.resolve(&backspace, KeyContext::Prompt),
Action::PromptBackspace,
"Backspace should resolve to PromptBackspace in Prompt context"
);
assert_eq!(
resolver.resolve(&shift_backspace, KeyContext::Prompt),
Action::PromptBackspace,
"Shift+Backspace should resolve to PromptBackspace (same as Backspace) in Prompt context"
);
assert_eq!(
resolver.resolve(&backspace, KeyContext::FileExplorer),
Action::FileExplorerSearchBackspace,
"Backspace should resolve to FileExplorerSearchBackspace in FileExplorer context"
);
assert_eq!(
resolver.resolve(&shift_backspace, KeyContext::FileExplorer),
Action::FileExplorerSearchBackspace,
"Shift+Backspace should resolve to FileExplorerSearchBackspace (same as Backspace) in FileExplorer context"
);
}
#[test]
fn test_file_explorer_ui_fallthrough() {
let config = Config::default();
let resolver = KeybindingResolver::new(&config);
let cases = [
(
KeyCode::PageUp,
KeyModifiers::CONTROL,
Action::PrevBuffer,
"Ctrl+PageUp -> prev_buffer",
),
(
KeyCode::PageDown,
KeyModifiers::CONTROL,
Action::NextBuffer,
"Ctrl+PageDown -> next_buffer",
),
(
KeyCode::PageUp,
KeyModifiers::ALT,
Action::ScrollTabsLeft,
"Alt+PageUp -> scroll_tabs_left",
),
(
KeyCode::PageDown,
KeyModifiers::ALT,
Action::ScrollTabsRight,
"Alt+PageDown -> scroll_tabs_right",
),
(
KeyCode::Char('w'),
KeyModifiers::ALT,
Action::CloseTab,
"Alt+W -> close_tab",
),
];
for (code, mods, expected, label) in cases {
let event = KeyEvent::new(code, mods);
assert_eq!(
resolver.resolve(&event, KeyContext::FileExplorer),
expected,
"{label} should fall through from FileExplorer to Normal"
);
}
let up = KeyEvent::new(KeyCode::Up, KeyModifiers::empty());
assert_eq!(
resolver.resolve(&up, KeyContext::FileExplorer),
Action::FileExplorerUp,
"Up must continue to navigate the explorer, not move the cursor"
);
let plain_d = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::empty());
assert_eq!(
resolver.resolve(&plain_d, KeyContext::FileExplorer),
Action::InsertChar('d'),
"Plain 'd' must remain text input for explorer search-as-you-type"
);
}
#[test]
fn test_action_from_str() {
let args = HashMap::new();
assert_eq!(Action::from_str("move_left", &args), Some(Action::MoveLeft));
assert_eq!(Action::from_str("save", &args), Some(Action::Save));
assert_eq!(
Action::from_str("unknown", &args),
Some(Action::PluginAction("unknown".to_string()))
);
assert_eq!(
Action::from_str("keyboard_shortcuts", &args),
Some(Action::ShowKeyboardShortcuts)
);
assert_eq!(
Action::from_str("prompt_confirm", &args),
Some(Action::PromptConfirm)
);
assert_eq!(
Action::from_str("popup_cancel", &args),
Some(Action::PopupCancel)
);
assert_eq!(
Action::from_str("calibrate_input", &args),
Some(Action::CalibrateInput)
);
}
#[test]
fn test_key_context_from_when_clause() {
assert_eq!(
KeyContext::from_when_clause("normal"),
Some(KeyContext::Normal)
);
assert_eq!(
KeyContext::from_when_clause("prompt"),
Some(KeyContext::Prompt)
);
assert_eq!(
KeyContext::from_when_clause("popup"),
Some(KeyContext::Popup)
);
assert_eq!(KeyContext::from_when_clause("help"), None);
assert_eq!(KeyContext::from_when_clause(" help "), None); assert_eq!(KeyContext::from_when_clause("unknown"), None);
assert_eq!(KeyContext::from_when_clause(""), None);
}
#[test]
fn test_key_context_to_when_clause() {
assert_eq!(KeyContext::Normal.to_when_clause(), "normal");
assert_eq!(KeyContext::Prompt.to_when_clause(), "prompt");
assert_eq!(KeyContext::Popup.to_when_clause(), "popup");
}
#[test]
fn test_context_specific_bindings() {
let config = Config::default();
let resolver = KeybindingResolver::new(&config);
let enter_event = KeyEvent::new(KeyCode::Enter, KeyModifiers::empty());
assert_eq!(
resolver.resolve(&enter_event, KeyContext::Prompt),
Action::PromptConfirm
);
assert_eq!(
resolver.resolve(&enter_event, KeyContext::Normal),
Action::InsertNewline
);
let up_event = KeyEvent::new(KeyCode::Up, KeyModifiers::empty());
assert_eq!(
resolver.resolve(&up_event, KeyContext::Popup),
Action::PopupSelectPrev
);
assert_eq!(
resolver.resolve(&up_event, KeyContext::Normal),
Action::MoveUp
);
}
#[test]
fn test_context_fallback_to_normal() {
let config = Config::default();
let resolver = KeybindingResolver::new(&config);
let save_event = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL);
assert_eq!(
resolver.resolve(&save_event, KeyContext::Normal),
Action::Save
);
assert_eq!(
resolver.resolve(&save_event, KeyContext::Popup),
Action::Save
);
}
#[test]
fn test_context_priority_resolution() {
use crate::config::Keybinding;
let mut config = Config::default();
config.keybindings.push(Keybinding {
key: "esc".to_string(),
modifiers: vec![],
keys: vec![],
action: "quit".to_string(), args: HashMap::new(),
when: Some("popup".to_string()),
});
let resolver = KeybindingResolver::new(&config);
let esc_event = KeyEvent::new(KeyCode::Esc, KeyModifiers::empty());
assert_eq!(
resolver.resolve(&esc_event, KeyContext::Popup),
Action::Quit
);
assert_eq!(
resolver.resolve(&esc_event, KeyContext::Normal),
Action::RemoveSecondaryCursors
);
}
#[test]
fn test_character_input_in_contexts() {
let config = Config::default();
let resolver = KeybindingResolver::new(&config);
let char_event = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty());
assert_eq!(
resolver.resolve(&char_event, KeyContext::Normal),
Action::InsertChar('a')
);
assert_eq!(
resolver.resolve(&char_event, KeyContext::Prompt),
Action::InsertChar('a')
);
assert_eq!(
resolver.resolve(&char_event, KeyContext::Popup),
Action::None
);
}
#[test]
fn test_custom_keybinding_loading() {
use crate::config::Keybinding;
let mut config = Config::default();
config.keybindings.push(Keybinding {
key: "f".to_string(),
modifiers: vec!["ctrl".to_string()],
keys: vec![],
action: "command_palette".to_string(),
args: HashMap::new(),
when: None, });
let resolver = KeybindingResolver::new(&config);
let ctrl_f = KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL);
assert_eq!(
resolver.resolve(&ctrl_f, KeyContext::Normal),
Action::CommandPalette
);
let ctrl_k = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL);
assert_eq!(
resolver.resolve(&ctrl_k, KeyContext::Prompt),
Action::PromptDeleteToLineEnd
);
assert_eq!(
resolver.resolve(&ctrl_k, KeyContext::Normal),
Action::DeleteToLineEnd
);
}
#[test]
fn test_all_context_default_bindings_exist() {
let config = Config::default();
let resolver = KeybindingResolver::new(&config);
assert!(resolver.default_bindings.contains_key(&KeyContext::Normal));
assert!(resolver.default_bindings.contains_key(&KeyContext::Prompt));
assert!(resolver.default_bindings.contains_key(&KeyContext::Popup));
assert!(resolver
.default_bindings
.contains_key(&KeyContext::FileExplorer));
assert!(resolver.default_bindings.contains_key(&KeyContext::Menu));
assert!(!resolver.default_bindings[&KeyContext::Normal].is_empty());
assert!(!resolver.default_bindings[&KeyContext::Prompt].is_empty());
assert!(!resolver.default_bindings[&KeyContext::Popup].is_empty());
assert!(!resolver.default_bindings[&KeyContext::FileExplorer].is_empty());
assert!(!resolver.default_bindings[&KeyContext::Menu].is_empty());
}
#[test]
fn test_all_builtin_keymaps_have_valid_action_names() {
let known_actions: std::collections::HashSet<String> =
Action::all_action_names().into_iter().collect();
const ALLOWED_PLUGIN_ACTIONS_IN_DEFAULTS: &[&str] = &[
"start_search_replace",
"live_grep_toggle_files",
"live_grep_toggle_ignored",
"live_grep_toggle_buffers",
"live_grep_toggle_terminals",
"live_grep_toggle_diagnostics",
"live_grep_toggle_word",
"live_grep_toggle_regex",
"live_grep_export_quickfix",
];
let config = Config::default();
for map_name in crate::config::KeybindingMapName::BUILTIN_OPTIONS {
let bindings = config.resolve_keymap(map_name);
for binding in &bindings {
let is_known_builtin = known_actions.contains(&binding.action);
let is_allowed_plugin =
ALLOWED_PLUGIN_ACTIONS_IN_DEFAULTS.contains(&binding.action.as_str());
assert!(
is_known_builtin || is_allowed_plugin,
"Keymap '{}' contains unknown action '{}' (key: '{}', when: {:?}). \
This will be treated as a plugin action at runtime. \
Check for typos in the keymap JSON file, or add the action to \
ALLOWED_PLUGIN_ACTIONS_IN_DEFAULTS if it's an intentional \
plugin-action binding.",
map_name,
binding.action,
binding.key,
binding.when,
);
}
}
}
#[test]
fn test_resolve_determinism() {
let config = Config::default();
let resolver = KeybindingResolver::new(&config);
let test_cases = vec![
(KeyCode::Left, KeyModifiers::empty(), KeyContext::Normal),
(
KeyCode::Esc,
KeyModifiers::empty(),
KeyContext::FileExplorer,
),
(KeyCode::Enter, KeyModifiers::empty(), KeyContext::Prompt),
(KeyCode::Down, KeyModifiers::empty(), KeyContext::Popup),
];
for (key_code, modifiers, context) in test_cases {
let event = KeyEvent::new(key_code, modifiers);
let action1 = resolver.resolve(&event, context.clone());
let action2 = resolver.resolve(&event, context.clone());
let action3 = resolver.resolve(&event, context);
assert_eq!(action1, action2, "Resolve should be deterministic");
assert_eq!(action2, action3, "Resolve should be deterministic");
}
}
#[test]
fn test_modifier_combinations() {
let config = Config::default();
let resolver = KeybindingResolver::new(&config);
let char_s = KeyCode::Char('s');
let no_mod = KeyEvent::new(char_s, KeyModifiers::empty());
let ctrl = KeyEvent::new(char_s, KeyModifiers::CONTROL);
let shift = KeyEvent::new(char_s, KeyModifiers::SHIFT);
let ctrl_shift = KeyEvent::new(char_s, KeyModifiers::CONTROL | KeyModifiers::SHIFT);
let action_no_mod = resolver.resolve(&no_mod, KeyContext::Normal);
let action_ctrl = resolver.resolve(&ctrl, KeyContext::Normal);
let action_shift = resolver.resolve(&shift, KeyContext::Normal);
let action_ctrl_shift = resolver.resolve(&ctrl_shift, KeyContext::Normal);
assert_eq!(action_no_mod, Action::InsertChar('s'));
assert_eq!(action_ctrl, Action::Save);
assert_eq!(action_shift, Action::InsertChar('s')); assert_eq!(action_ctrl_shift, Action::None);
}
#[test]
fn test_scroll_keybindings() {
let config = Config::default();
let resolver = KeybindingResolver::new(&config);
let ctrl_up = KeyEvent::new(KeyCode::Up, KeyModifiers::CONTROL);
assert_eq!(
resolver.resolve(&ctrl_up, KeyContext::Normal),
Action::ScrollUp,
"Ctrl+Up should resolve to ScrollUp"
);
let ctrl_down = KeyEvent::new(KeyCode::Down, KeyModifiers::CONTROL);
assert_eq!(
resolver.resolve(&ctrl_down, KeyContext::Normal),
Action::ScrollDown,
"Ctrl+Down should resolve to ScrollDown"
);
}
#[test]
fn test_lsp_completion_keybinding() {
let config = Config::default();
let resolver = KeybindingResolver::new(&config);
let ctrl_space = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL);
assert_eq!(
resolver.resolve(&ctrl_space, KeyContext::Normal),
Action::LspCompletion,
"Ctrl+Space should resolve to LspCompletion"
);
}
#[test]
fn test_terminal_key_equivalents() {
let ctrl = KeyModifiers::CONTROL;
let slash_equivs = terminal_key_equivalents(KeyCode::Char('/'), ctrl);
assert_eq!(slash_equivs, vec![(KeyCode::Char('7'), ctrl)]);
let seven_equivs = terminal_key_equivalents(KeyCode::Char('7'), ctrl);
assert_eq!(seven_equivs, vec![(KeyCode::Char('/'), ctrl)]);
let backspace_equivs = terminal_key_equivalents(KeyCode::Backspace, ctrl);
assert_eq!(backspace_equivs, vec![(KeyCode::Char('h'), ctrl)]);
let h_equivs = terminal_key_equivalents(KeyCode::Char('h'), ctrl);
assert_eq!(h_equivs, vec![(KeyCode::Backspace, ctrl)]);
let a_equivs = terminal_key_equivalents(KeyCode::Char('a'), ctrl);
assert!(a_equivs.is_empty());
let slash_no_ctrl = terminal_key_equivalents(KeyCode::Char('/'), KeyModifiers::empty());
assert!(slash_no_ctrl.is_empty());
}
#[test]
fn test_terminal_key_equivalents_auto_binding() {
let config = Config::default();
let resolver = KeybindingResolver::new(&config);
let ctrl_slash = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::CONTROL);
let action_slash = resolver.resolve(&ctrl_slash, KeyContext::Normal);
assert_eq!(
action_slash,
Action::ToggleComment,
"Ctrl+/ should resolve to ToggleComment"
);
let ctrl_7 = KeyEvent::new(KeyCode::Char('7'), KeyModifiers::CONTROL);
let action_7 = resolver.resolve(&ctrl_7, KeyContext::Normal);
assert_eq!(
action_7,
Action::ToggleComment,
"Ctrl+7 should resolve to ToggleComment (terminal equivalent of Ctrl+/)"
);
}
#[test]
fn test_terminal_key_equivalents_normalization() {
let ctrl = KeyModifiers::CONTROL;
let slash_equivs = terminal_key_equivalents(KeyCode::Char('/'), ctrl);
assert_eq!(
slash_equivs,
vec![(KeyCode::Char('7'), ctrl)],
"Ctrl+/ should map to Ctrl+7"
);
let seven_equivs = terminal_key_equivalents(KeyCode::Char('7'), ctrl);
assert_eq!(
seven_equivs,
vec![(KeyCode::Char('/'), ctrl)],
"Ctrl+7 should map back to Ctrl+/"
);
let backspace_equivs = terminal_key_equivalents(KeyCode::Backspace, ctrl);
assert_eq!(
backspace_equivs,
vec![(KeyCode::Char('h'), ctrl)],
"Ctrl+Backspace should map to Ctrl+H"
);
let h_equivs = terminal_key_equivalents(KeyCode::Char('h'), ctrl);
assert_eq!(
h_equivs,
vec![(KeyCode::Backspace, ctrl)],
"Ctrl+H should map back to Ctrl+Backspace"
);
let space_equivs = terminal_key_equivalents(KeyCode::Char(' '), ctrl);
assert_eq!(
space_equivs,
vec![(KeyCode::Char('@'), ctrl)],
"Ctrl+Space should map to Ctrl+@"
);
let at_equivs = terminal_key_equivalents(KeyCode::Char('@'), ctrl);
assert_eq!(
at_equivs,
vec![(KeyCode::Char(' '), ctrl)],
"Ctrl+@ should map back to Ctrl+Space"
);
let minus_equivs = terminal_key_equivalents(KeyCode::Char('-'), ctrl);
assert_eq!(
minus_equivs,
vec![(KeyCode::Char('_'), ctrl)],
"Ctrl+- should map to Ctrl+_"
);
let underscore_equivs = terminal_key_equivalents(KeyCode::Char('_'), ctrl);
assert_eq!(
underscore_equivs,
vec![(KeyCode::Char('-'), ctrl)],
"Ctrl+_ should map back to Ctrl+-"
);
assert!(
terminal_key_equivalents(KeyCode::Char('a'), ctrl).is_empty(),
"Ctrl+A should have no terminal equivalents"
);
assert!(
terminal_key_equivalents(KeyCode::Char('z'), ctrl).is_empty(),
"Ctrl+Z should have no terminal equivalents"
);
assert!(
terminal_key_equivalents(KeyCode::Enter, ctrl).is_empty(),
"Ctrl+Enter should have no terminal equivalents"
);
assert!(
terminal_key_equivalents(KeyCode::Char('/'), KeyModifiers::empty()).is_empty(),
"/ without Ctrl should have no equivalents"
);
assert!(
terminal_key_equivalents(KeyCode::Char('7'), KeyModifiers::SHIFT).is_empty(),
"Shift+7 should have no equivalents"
);
assert!(
terminal_key_equivalents(KeyCode::Char('h'), KeyModifiers::ALT).is_empty(),
"Alt+H should have no equivalents"
);
let ctrl_shift = KeyModifiers::CONTROL | KeyModifiers::SHIFT;
let ctrl_shift_h_equivs = terminal_key_equivalents(KeyCode::Char('h'), ctrl_shift);
assert!(
ctrl_shift_h_equivs.is_empty(),
"Ctrl+Shift+H should NOT map to Ctrl+Shift+Backspace"
);
}
#[test]
fn test_no_duplicate_keybindings_in_keymaps() {
use std::collections::HashMap;
let keymaps: &[(&str, &str)] = &[
("default", include_str!("../../keymaps/default.json")),
("macos", include_str!("../../keymaps/macos.json")),
];
for (keymap_name, json_content) in keymaps {
let keymap: crate::config::KeymapConfig = serde_json::from_str(json_content)
.unwrap_or_else(|e| panic!("Failed to parse keymap '{}': {}", keymap_name, e));
let mut seen: HashMap<(String, Vec<String>, String), String> = HashMap::new();
let mut duplicates: Vec<String> = Vec::new();
for binding in &keymap.bindings {
let when = binding.when.clone().unwrap_or_default();
let key_id = (binding.key.clone(), binding.modifiers.clone(), when.clone());
if let Some(existing_action) = seen.get(&key_id) {
duplicates.push(format!(
"Duplicate in '{}': key='{}', modifiers={:?}, when='{}' -> '{}' vs '{}'",
keymap_name,
binding.key,
binding.modifiers,
when,
existing_action,
binding.action
));
} else {
seen.insert(key_id, binding.action.clone());
}
}
assert!(
duplicates.is_empty(),
"Found duplicate keybindings:\n{}",
duplicates.join("\n")
);
}
}
}