use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::collections::HashMap;
use crate::buffer::AppMode;
use crate::ui::input::actions::{Action, ActionContext, CursorPosition, NavigateAction};
pub struct KeyMapper {
global_mappings: HashMap<(KeyCode, KeyModifiers), Action>,
mode_mappings: HashMap<AppMode, HashMap<(KeyCode, KeyModifiers), Action>>,
count_buffer: String,
vim_command_buffer: String,
}
impl KeyMapper {
#[must_use]
pub fn new() -> Self {
let mut mapper = Self {
global_mappings: HashMap::new(),
mode_mappings: HashMap::new(),
count_buffer: String::new(),
vim_command_buffer: String::new(),
};
mapper.init_global_mappings();
mapper.init_mode_mappings();
mapper
}
fn init_global_mappings(&mut self) {
use KeyCode::{Char, F};
use KeyModifiers as Mod;
self.global_mappings
.insert((F(1), Mod::NONE), Action::ShowHelp);
self.global_mappings
.insert((F(3), Mod::NONE), Action::ShowPrettyQuery);
self.global_mappings
.insert((F(5), Mod::NONE), Action::ShowDebugInfo);
self.global_mappings
.insert((F(6), Mod::NONE), Action::ToggleRowNumbers);
self.global_mappings
.insert((F(7), Mod::NONE), Action::ToggleCompactMode);
self.global_mappings
.insert((F(8), Mod::NONE), Action::ToggleCaseInsensitive);
self.global_mappings
.insert((F(9), Mod::NONE), Action::KillLine);
self.global_mappings
.insert((F(10), Mod::NONE), Action::KillLineBackward);
self.global_mappings
.insert((F(12), Mod::NONE), Action::ToggleKeyIndicator);
self.global_mappings
.insert((Char('c'), Mod::CONTROL), Action::ForceQuit);
self.global_mappings
.insert((Char('C'), Mod::CONTROL), Action::ForceQuit);
}
fn init_mode_mappings(&mut self) {
self.init_results_mappings();
self.init_command_mappings();
}
fn init_results_mappings(&mut self) {
use crate::buffer::AppMode;
use KeyCode::{Char, Down, End, Esc, Home, Left, PageDown, PageUp, Right, Up, F};
use KeyModifiers as Mod;
let mut mappings = HashMap::new();
mappings.insert((Up, Mod::NONE), Action::Navigate(NavigateAction::Up(1)));
mappings.insert((Down, Mod::NONE), Action::Navigate(NavigateAction::Down(1)));
mappings.insert((Left, Mod::NONE), Action::Navigate(NavigateAction::Left(1)));
mappings.insert(
(Right, Mod::NONE),
Action::Navigate(NavigateAction::Right(1)),
);
mappings.insert(
(PageUp, Mod::NONE),
Action::Navigate(NavigateAction::PageUp),
);
mappings.insert(
(PageDown, Mod::NONE),
Action::Navigate(NavigateAction::PageDown),
);
mappings.insert(
(Char('f'), Mod::CONTROL),
Action::Navigate(NavigateAction::PageDown),
);
mappings.insert(
(Char('b'), Mod::CONTROL),
Action::Navigate(NavigateAction::PageUp),
);
mappings.insert((Home, Mod::NONE), Action::Navigate(NavigateAction::Home));
mappings.insert((End, Mod::NONE), Action::Navigate(NavigateAction::End));
mappings.insert(
(Char('h'), Mod::NONE),
Action::Navigate(NavigateAction::Left(1)),
);
mappings.insert(
(Char('j'), Mod::NONE),
Action::Navigate(NavigateAction::Down(1)),
);
mappings.insert(
(Char('k'), Mod::NONE),
Action::Navigate(NavigateAction::Up(1)),
);
mappings.insert(
(Char('l'), Mod::NONE),
Action::Navigate(NavigateAction::Right(1)),
);
mappings.insert((Left, Mod::NONE), Action::Navigate(NavigateAction::Left(1)));
mappings.insert(
(Right, Mod::NONE),
Action::Navigate(NavigateAction::Right(1)),
);
mappings.insert((Down, Mod::NONE), Action::Navigate(NavigateAction::Down(1)));
mappings.insert((Up, Mod::NONE), Action::Navigate(NavigateAction::Up(1)));
mappings.insert(
(PageUp, Mod::NONE),
Action::Navigate(NavigateAction::PageUp),
);
mappings.insert(
(PageDown, Mod::NONE),
Action::Navigate(NavigateAction::PageDown),
);
mappings.insert(
(Char('G'), Mod::SHIFT),
Action::Navigate(NavigateAction::End),
);
mappings.insert(
(Char('0'), Mod::NONE),
Action::Navigate(NavigateAction::FirstColumn),
);
mappings.insert(
(Char('^'), Mod::NONE),
Action::Navigate(NavigateAction::FirstColumn),
);
mappings.insert(
(Char('$'), Mod::NONE),
Action::Navigate(NavigateAction::LastColumn),
);
mappings.insert((Char('H'), Mod::SHIFT), Action::NavigateToViewportTop);
mappings.insert((Char('M'), Mod::SHIFT), Action::NavigateToViewportMiddle);
mappings.insert((Char('L'), Mod::SHIFT), Action::NavigateToViewportBottom);
mappings.insert((Esc, Mod::NONE), Action::ExitCurrentMode);
mappings.insert((Char('q'), Mod::NONE), Action::Quit);
mappings.insert((Char('c'), Mod::CONTROL), Action::Quit);
mappings.insert((F(2), Mod::NONE), Action::SwitchMode(AppMode::Command));
mappings.insert(
(Char('i'), Mod::NONE),
Action::SwitchModeWithCursor(AppMode::Command, CursorPosition::Current),
);
mappings.insert(
(Char('a'), Mod::NONE),
Action::SwitchModeWithCursor(AppMode::Command, CursorPosition::End),
);
mappings.insert((Char('p'), Mod::NONE), Action::ToggleColumnPin);
mappings.insert((Char('-'), Mod::NONE), Action::HideColumn); mappings.insert(
(Char('H'), Mod::CONTROL | Mod::SHIFT),
Action::UnhideAllColumns,
);
mappings.insert((Char('+'), Mod::NONE), Action::UnhideAllColumns); mappings.insert((Char('='), Mod::NONE), Action::UnhideAllColumns); mappings.insert((Char('e'), Mod::NONE), Action::HideEmptyColumns);
mappings.insert((Char('E'), Mod::SHIFT), Action::HideEmptyColumns);
mappings.insert((Left, Mod::SHIFT), Action::MoveColumnLeft);
mappings.insert((Right, Mod::SHIFT), Action::MoveColumnRight);
mappings.insert((Char('<'), Mod::NONE), Action::MoveColumnLeft);
mappings.insert((Char('>'), Mod::NONE), Action::MoveColumnRight);
mappings.insert((Char('/'), Mod::NONE), Action::StartSearch);
mappings.insert((Char('\\'), Mod::NONE), Action::StartColumnSearch);
mappings.insert((Char('f'), Mod::NONE), Action::StartFilter);
mappings.insert((Char('F'), Mod::SHIFT), Action::StartFuzzyFilter);
mappings.insert((Char('s'), Mod::NONE), Action::Sort(None));
mappings.insert((Char('N'), Mod::NONE), Action::ToggleRowNumbers);
mappings.insert((Char('C'), Mod::NONE), Action::ToggleCompactMode);
mappings.insert((Char('x'), Mod::CONTROL), Action::ExportToCsv);
mappings.insert((Char('j'), Mod::CONTROL), Action::ExportToJson);
mappings.insert((Char('C'), Mod::SHIFT), Action::ClearFilter);
mappings.insert((Char(':'), Mod::NONE), Action::StartJumpToRow);
mappings.insert((Char('n'), Mod::NONE), Action::NextSearchMatch);
mappings.insert((Char('N'), Mod::SHIFT), Action::PreviousSearchMatch);
mappings.insert((Char('v'), Mod::NONE), Action::ToggleSelectionMode);
mappings.insert((Char('S'), Mod::SHIFT), Action::ShowColumnStatistics);
mappings.insert((Char('s'), Mod::ALT), Action::CycleColumnPacking);
mappings.insert((Char(' '), Mod::NONE), Action::ToggleViewportLock);
mappings.insert((Char('x'), Mod::NONE), Action::ToggleCursorLock);
mappings.insert((Char('X'), Mod::SHIFT), Action::ToggleCursorLock);
mappings.insert((Char(' '), Mod::CONTROL), Action::ToggleViewportLock);
mappings.insert((Char('?'), Mod::NONE), Action::ShowHelp);
mappings.insert((Char('P'), Mod::SHIFT), Action::ClearAllPins);
mappings.insert((Char('r'), Mod::CONTROL), Action::StartHistorySearch);
self.mode_mappings.insert(AppMode::Results, mappings);
}
fn init_command_mappings(&mut self) {
use crate::buffer::AppMode;
use KeyCode::{Backspace, Char, Delete, Down, End, Enter, Home, Left, Right, Up, F};
use KeyModifiers as Mod;
let mut mappings = HashMap::new();
mappings.insert((Enter, Mod::NONE), Action::ExecuteQuery);
mappings.insert((F(2), Mod::NONE), Action::SwitchMode(AppMode::Results));
mappings.insert((Char('u'), Mod::CONTROL), Action::ClearLine);
mappings.insert((Char('z'), Mod::CONTROL), Action::Undo);
mappings.insert((Char('y'), Mod::CONTROL), Action::Redo);
mappings.insert((Left, Mod::NONE), Action::MoveCursorLeft);
mappings.insert((Right, Mod::NONE), Action::MoveCursorRight);
mappings.insert((Down, Mod::NONE), Action::SwitchMode(AppMode::Results)); mappings.insert((Home, Mod::NONE), Action::MoveCursorHome);
mappings.insert((End, Mod::NONE), Action::MoveCursorEnd);
mappings.insert((Char('a'), Mod::CONTROL), Action::MoveCursorHome);
mappings.insert((Char('e'), Mod::CONTROL), Action::MoveCursorEnd);
mappings.insert((Left, Mod::CONTROL), Action::MoveCursorWordLeft);
mappings.insert((Right, Mod::CONTROL), Action::MoveCursorWordRight);
mappings.insert((Char('b'), Mod::ALT), Action::MoveCursorWordLeft);
mappings.insert((Char('f'), Mod::ALT), Action::MoveCursorWordRight);
mappings.insert((Char('['), Mod::ALT), Action::JumpToPrevToken);
mappings.insert((Char(']'), Mod::ALT), Action::JumpToNextToken);
mappings.insert((Char(','), Mod::ALT), Action::JumpToPrevToken);
mappings.insert((Char('.'), Mod::ALT), Action::JumpToNextToken);
mappings.insert((Backspace, Mod::NONE), Action::Backspace);
mappings.insert((Delete, Mod::NONE), Action::Delete);
mappings.insert((Char('w'), Mod::CONTROL), Action::DeleteWordBackward);
mappings.insert((Char('d'), Mod::ALT), Action::DeleteWordForward);
mappings.insert((Char('k'), Mod::CONTROL), Action::KillLine);
mappings.insert((Char('v'), Mod::CONTROL), Action::Paste);
mappings.insert((Char('p'), Mod::CONTROL), Action::PreviousHistoryCommand);
mappings.insert((Char('n'), Mod::CONTROL), Action::NextHistoryCommand);
mappings.insert((Up, Mod::ALT), Action::PreviousHistoryCommand);
mappings.insert((Down, Mod::ALT), Action::NextHistoryCommand);
mappings.insert((Char('*'), Mod::CONTROL), Action::ExpandAsterisk);
mappings.insert((Char('*'), Mod::ALT), Action::ExpandAsteriskVisible);
self.mode_mappings.insert(AppMode::Command, mappings);
}
pub fn map_key(&mut self, key: KeyEvent, context: &ActionContext) -> Option<Action> {
if context.mode == AppMode::Results {
if let KeyCode::Char(c) = key.code {
if key.modifiers.is_empty() {
if !self.vim_command_buffer.is_empty() {
let command = format!("{}{}", self.vim_command_buffer, c);
let action = if command.as_str() == "gg" {
self.vim_command_buffer.clear();
Some(Action::Navigate(NavigateAction::Home))
} else {
self.vim_command_buffer.clear();
None
};
if action.is_some() {
return action;
}
}
if c.is_ascii_digit() {
self.count_buffer.push(c);
return None; }
if c == 'g' {
let key_combo = (key.code, key.modifiers);
if let Some(mode_mappings) = self.mode_mappings.get(&context.mode) {
if mode_mappings.contains_key(&key_combo) {
tracing::debug!(
"Key '{}' has standalone mapping, not treating as vim command",
c
);
} else {
self.vim_command_buffer.push(c);
tracing::debug!("Starting vim command buffer with '{}'", c);
return None; }
}
}
}
}
}
let action = self.map_key_internal(key, context);
if !self.count_buffer.is_empty() {
if let Some(mut action) = action {
if let Ok(count) = self.count_buffer.parse::<usize>() {
action = self.apply_count_to_action(action, count);
}
self.count_buffer.clear();
return Some(action);
}
self.count_buffer.clear();
}
action
}
fn map_key_internal(&self, key: KeyEvent, context: &ActionContext) -> Option<Action> {
let key_combo = (key.code, key.modifiers);
if let Some(action) = self.global_mappings.get(&key_combo) {
return Some(action.clone());
}
if let Some(mode_mappings) = self.mode_mappings.get(&context.mode) {
if let Some(action) = mode_mappings.get(&key_combo) {
return Some(action.clone());
}
}
if context.mode == AppMode::Command {
if let KeyCode::Char(c) = key.code {
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT {
return Some(Action::InsertChar(c));
}
}
}
None
}
fn apply_count_to_action(&self, action: Action, count: usize) -> Action {
match action {
Action::Navigate(NavigateAction::Up(_)) => Action::Navigate(NavigateAction::Up(count)),
Action::Navigate(NavigateAction::Down(_)) => {
Action::Navigate(NavigateAction::Down(count))
}
Action::Navigate(NavigateAction::Left(_)) => {
Action::Navigate(NavigateAction::Left(count))
}
Action::Navigate(NavigateAction::Right(_)) => {
Action::Navigate(NavigateAction::Right(count))
}
_ => action,
}
}
pub fn clear_pending(&mut self) {
self.count_buffer.clear();
self.vim_command_buffer.clear();
}
#[must_use]
pub fn is_collecting_count(&self) -> bool {
!self.count_buffer.is_empty()
}
#[must_use]
pub fn get_count_buffer(&self) -> &str {
&self.count_buffer
}
}
impl Default for KeyMapper {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app_state_container::SelectionMode;
#[test]
fn test_basic_navigation_mapping() {
let mut mapper = KeyMapper::new();
let context = ActionContext {
mode: AppMode::Results,
selection_mode: SelectionMode::Row,
has_results: true,
has_filter: false,
has_search: false,
row_count: 100,
column_count: 10,
current_row: 0,
current_column: 0,
};
let key = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
let action = mapper.map_key(key, &context);
assert_eq!(action, Some(Action::Navigate(NavigateAction::Down(1))));
let key = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
let action = mapper.map_key(key, &context);
assert_eq!(action, Some(Action::Navigate(NavigateAction::Down(1))));
}
#[test]
fn test_vim_count_motion() {
let mut mapper = KeyMapper::new();
let context = ActionContext {
mode: AppMode::Results,
selection_mode: SelectionMode::Row,
has_results: true,
has_filter: false,
has_search: false,
row_count: 100,
column_count: 10,
current_row: 0,
current_column: 0,
};
let key = KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE);
let action = mapper.map_key(key, &context);
assert_eq!(action, None); assert_eq!(mapper.get_count_buffer(), "5");
let key = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
let action = mapper.map_key(key, &context);
assert_eq!(action, Some(Action::Navigate(NavigateAction::Down(5))));
assert_eq!(mapper.get_count_buffer(), ""); }
#[test]
fn test_global_mapping_override() {
let mut mapper = KeyMapper::new();
let context = ActionContext {
mode: AppMode::Results,
selection_mode: SelectionMode::Row,
has_results: true,
has_filter: false,
has_search: false,
row_count: 100,
column_count: 10,
current_row: 0,
current_column: 0,
};
let key = KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE);
let action = mapper.map_key(key, &context);
assert_eq!(action, Some(Action::ShowHelp));
}
#[test]
fn test_command_mode_editing_actions() {
let mut mapper = KeyMapper::new();
let context = ActionContext {
mode: AppMode::Command,
selection_mode: SelectionMode::Row,
has_results: false,
has_filter: false,
has_search: false,
row_count: 0,
column_count: 0,
current_row: 0,
current_column: 0,
};
let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
let action = mapper.map_key(key, &context);
assert_eq!(action, Some(Action::InsertChar('a')));
let key = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);
let action = mapper.map_key(key, &context);
assert_eq!(action, Some(Action::InsertChar('A')));
let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
let action = mapper.map_key(key, &context);
assert_eq!(action, Some(Action::Backspace));
let key = KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE);
let action = mapper.map_key(key, &context);
assert_eq!(action, Some(Action::Delete));
let key = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);
let action = mapper.map_key(key, &context);
assert_eq!(action, Some(Action::MoveCursorLeft));
let key = KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
let action = mapper.map_key(key, &context);
assert_eq!(action, Some(Action::MoveCursorRight));
let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL);
let action = mapper.map_key(key, &context);
assert_eq!(action, Some(Action::MoveCursorHome));
let key = KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL);
let action = mapper.map_key(key, &context);
assert_eq!(action, Some(Action::MoveCursorEnd));
let key = KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL);
let action = mapper.map_key(key, &context);
assert_eq!(action, Some(Action::ClearLine));
let key = KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL);
let action = mapper.map_key(key, &context);
assert_eq!(action, Some(Action::DeleteWordBackward));
let key = KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL);
let action = mapper.map_key(key, &context);
assert_eq!(action, Some(Action::Undo));
let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
let action = mapper.map_key(key, &context);
assert_eq!(action, Some(Action::ExecuteQuery));
}
#[test]
fn test_vim_style_append_modes() {
let mut mapper = KeyMapper::new();
let context = ActionContext {
mode: AppMode::Results,
selection_mode: SelectionMode::Row,
has_results: true,
has_filter: false,
has_search: false,
row_count: 100,
column_count: 10,
current_row: 0,
current_column: 0,
};
let key = KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE);
let action = mapper.map_key(key, &context);
assert_eq!(
action,
Some(Action::SwitchModeWithCursor(
AppMode::Command,
CursorPosition::Current
))
);
let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
let action = mapper.map_key(key, &context);
assert_eq!(
action,
Some(Action::SwitchModeWithCursor(
AppMode::Command,
CursorPosition::End
))
);
}
#[test]
fn test_sort_key_mapping() {
let mut mapper = KeyMapper::new();
let context = ActionContext {
mode: AppMode::Results,
selection_mode: SelectionMode::Row,
has_results: true,
has_filter: false,
has_search: false,
row_count: 100,
column_count: 10,
current_row: 0,
current_column: 0,
};
let key = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE);
let action = mapper.map_key(key, &context);
assert_eq!(action, Some(Action::Sort(None)));
}
#[test]
fn test_vim_go_to_top() {
let mut mapper = KeyMapper::new();
let context = ActionContext {
mode: AppMode::Results,
selection_mode: SelectionMode::Row,
has_results: true,
has_filter: false,
has_search: false,
row_count: 100,
column_count: 10,
current_row: 0,
current_column: 0,
};
let key_g1 = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE);
let action_g1 = mapper.map_key(key_g1, &context);
assert_eq!(action_g1, None);
let key_g2 = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE);
let action_gg = mapper.map_key(key_g2, &context);
assert_eq!(action_gg, Some(Action::Navigate(NavigateAction::Home)));
}
#[test]
fn test_bug_reproduction_s_key_not_found() {
let mut mapper = KeyMapper::new();
let context = ActionContext {
mode: AppMode::Results,
selection_mode: SelectionMode::Row,
has_results: true,
has_filter: false,
has_search: false,
row_count: 100,
column_count: 10,
current_row: 0,
current_column: 0,
};
let key = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE);
let action = mapper.map_key(key, &context);
assert!(
action.is_some(),
"Bug reproduction: 's' key should map to an action, not return None"
);
assert_eq!(
action,
Some(Action::Sort(None)),
"Bug reproduction: 's' key should map to Sort action"
);
}
}