aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-14 01:04:51 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-14 01:04:51 +0300
commitedaa3a1a092763cb25fd9e21772c95ee56a75a15 (patch)
tree88d54f6a3aa2a43c1c6dd4c7aaabd78ddfc03466 /src
parent5ae1cea94bcd77c8a75abfbe6c996e5cf2a91ab7 (diff)
fix unify app key normalization for enhanced keyboard reporting
Diffstat (limited to 'src')
-rw-r--r--src/app/input.rs270
-rw-r--r--src/app/state.rs5
-rw-r--r--src/config.rs49
-rw-r--r--src/input/mod.rs5
-rw-r--r--src/input/model.rs52
5 files changed, 295 insertions, 86 deletions
diff --git a/src/app/input.rs b/src/app/input.rs
index 10e5a007..c3717439 100644
--- a/src/app/input.rs
+++ b/src/app/input.rs
@@ -28,8 +28,8 @@ const WORKSPACE_DRAG_THRESHOLD: u16 = 1;
const TAB_DRAG_THRESHOLD: u16 = 1;
use super::state::{
- key_matches, AgentPanelScope, AppState, ContextMenuKind, ContextMenuState, DragState,
- DragTarget, MenuListState, Mode, TabPressState, WorkspacePressState,
+ AgentPanelScope, AppState, ContextMenuKind, ContextMenuState, DragState, DragTarget,
+ MenuListState, Mode, TabPressState, WorkspacePressState,
};
use super::App;
@@ -37,57 +37,123 @@ use super::App;
// Key handling
// ---------------------------------------------------------------------------
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct AppKey {
+ raw: KeyEvent,
+ code: KeyCode,
+ modifiers: crossterm::event::KeyModifiers,
+}
+
+impl AppKey {
+ fn code(self) -> KeyCode {
+ self.code
+ }
+
+ fn modifiers(self) -> crossterm::event::KeyModifiers {
+ self.modifiers
+ }
+
+ fn raw_event(self) -> KeyEvent {
+ self.raw
+ }
+
+ fn matches_binding(
+ self,
+ expected_code: KeyCode,
+ expected_modifiers: crossterm::event::KeyModifiers,
+ ) -> bool {
+ let (expected_code, expected_modifiers) =
+ crate::input::normalize_app_key_binding(expected_code, expected_modifiers, None);
+ self.code == expected_code && self.modifiers == expected_modifiers
+ }
+
+ fn text_char(self) -> Option<char> {
+ match self.code {
+ KeyCode::Char(ch) if self.modifiers.is_empty() => Some(ch),
+ _ => None,
+ }
+ }
+}
+
+impl From<TerminalKey> for AppKey {
+ fn from(value: TerminalKey) -> Self {
+ let raw = value.as_key_event();
+ let (code, modifiers) = crate::input::normalize_app_key_binding(
+ value.code,
+ value.modifiers,
+ value.shifted_codepoint,
+ );
+ Self {
+ raw,
+ code,
+ modifiers,
+ }
+ }
+}
+
+impl From<KeyEvent> for AppKey {
+ fn from(value: KeyEvent) -> Self {
+ let (code, modifiers) =
+ crate::input::normalize_app_key_binding(value.code, value.modifiers, None);
+ Self {
+ raw: value,
+ code,
+ modifiers,
+ }
+ }
+}
+
fn is_modifier_only_key(code: &KeyCode) -> bool {
matches!(code, KeyCode::Modifier(_))
}
-fn terminal_direct_navigation_action(state: &AppState, key: &KeyEvent) -> Option<NavigateAction> {
+fn terminal_direct_navigation_action(state: &AppState, key: &AppKey) -> Option<NavigateAction> {
let kb = &state.keybinds;
if kb
.previous_workspace
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ .is_some_and(|(code, mods)| key.matches_binding(code, mods))
{
return Some(NavigateAction::PreviousWorkspace);
}
if kb
.next_workspace
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ .is_some_and(|(code, mods)| key.matches_binding(code, mods))
{
return Some(NavigateAction::NextWorkspace);
}
if kb
.previous_tab
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ .is_some_and(|(code, mods)| key.matches_binding(code, mods))
{
return Some(NavigateAction::PreviousTab);
}
if kb
.next_tab
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ .is_some_and(|(code, mods)| key.matches_binding(code, mods))
{
return Some(NavigateAction::NextTab);
}
if kb
.focus_pane_left
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ .is_some_and(|(code, mods)| key.matches_binding(code, mods))
{
return Some(NavigateAction::FocusPaneLeft);
}
if kb
.focus_pane_down
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ .is_some_and(|(code, mods)| key.matches_binding(code, mods))
{
return Some(NavigateAction::FocusPaneDown);
}
if kb
.focus_pane_up
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ .is_some_and(|(code, mods)| key.matches_binding(code, mods))
{
return Some(NavigateAction::FocusPaneUp);
}
if kb
.focus_pane_right
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ .is_some_and(|(code, mods)| key.matches_binding(code, mods))
{
return Some(NavigateAction::FocusPaneRight);
}
@@ -99,7 +165,7 @@ impl App {
match self.state.mode {
Mode::Terminal => self.handle_terminal_key(key).await,
_ => {
- let key = key.as_key_event();
+ let key = AppKey::from(key);
match self.state.mode {
Mode::Onboarding => self.handle_onboarding_key(key),
Mode::ReleaseNotes => self.handle_release_notes_key(key),
@@ -130,9 +196,9 @@ impl App {
}
}
- fn handle_onboarding_key(&mut self, key: KeyEvent) {
+ fn handle_onboarding_key(&mut self, key: AppKey) {
match self.state.onboarding_step {
- 0 => match key.code {
+ 0 => match key.code() {
KeyCode::Right | KeyCode::Char('l') => {
self.state.onboarding_step = 1;
}
@@ -141,7 +207,7 @@ impl App {
_ => {}
},
},
- _ => match key.code {
+ _ => match key.code() {
KeyCode::Up | KeyCode::Char('k') => self.state.onboarding_list.move_prev(),
KeyCode::Down | KeyCode::Char('j') => self.state.onboarding_list.move_next(4),
KeyCode::Left | KeyCode::Char('h') => {
@@ -161,8 +227,8 @@ impl App {
}
}
- fn handle_release_notes_key(&mut self, key: KeyEvent) {
- match key.code {
+ fn handle_release_notes_key(&mut self, key: AppKey) {
+ match key.code() {
KeyCode::Up | KeyCode::Char('k') => self.scroll_release_notes(-1),
KeyCode::Down | KeyCode::Char('j') => self.scroll_release_notes(1),
KeyCode::PageUp => self.scroll_release_notes(-8),
@@ -185,7 +251,7 @@ impl App {
}
}
- fn handle_settings_key(&mut self, key: KeyEvent) {
+ fn handle_settings_key(&mut self, key: AppKey) {
if let Some(action) = update_settings_state(&mut self.state, key) {
match action {
SettingsAction::SaveTheme(name) => self.save_theme(&name),
@@ -195,10 +261,12 @@ impl App {
}
}
- fn handle_navigate_key(&mut self, key: KeyEvent) {
+ fn handle_navigate_key(&mut self, key: AppKey) {
self.state.update_dismissed = true;
- if self.state.is_prefix(&key) || key.code == KeyCode::Esc {
+ if key.matches_binding(self.state.prefix_code, self.state.prefix_mods)
+ || key.code() == KeyCode::Esc
+ {
leave_navigate_mode(&mut self.state);
return;
}
@@ -360,9 +428,10 @@ impl App {
self.state.clear_selection();
self.state.update_dismissed = true;
- let key_event = key.as_key_event();
+ let app_key = AppKey::from(key);
+ let key_event = app_key.raw_event();
- if let Some(action) = terminal_direct_navigation_action(&self.state, &key_event) {
+ if let Some(action) = terminal_direct_navigation_action(&self.state, &app_key) {
debug!(
code = ?key_event.code,
modifiers = ?key_event.modifiers,
@@ -374,7 +443,7 @@ impl App {
return;
}
- if self.state.is_prefix(&key_event) {
+ if app_key.matches_binding(self.state.prefix_code, self.state.prefix_mods) {
self.state.mode = Mode::Navigate;
return;
}
@@ -460,13 +529,13 @@ enum ModalKeyBinding {
}
impl ModalKeyBinding {
- fn matches(self, key: &KeyEvent) -> bool {
+ fn matches(self, key: &AppKey) -> bool {
match self {
- Self::Enter => key.code == KeyCode::Enter,
- Self::Esc => key.code == KeyCode::Esc,
+ Self::Enter => key.code() == KeyCode::Enter,
+ Self::Esc => key.code() == KeyCode::Esc,
Self::CtrlC => {
- key.code == KeyCode::Char('c')
- && key.modifiers == crossterm::event::KeyModifiers::CONTROL
+ key.code() == KeyCode::Char('c')
+ && key.modifiers() == crossterm::event::KeyModifiers::CONTROL
}
}
}
@@ -478,7 +547,7 @@ struct ModalActionSpec<A> {
bindings: &'static [ModalKeyBinding],
}
-fn modal_action_from_key<A: Copy>(key: &KeyEvent, specs: &[ModalActionSpec<A>]) -> Option<A> {
+fn modal_action_from_key<A: Copy>(key: &AppKey, specs: &[ModalActionSpec<A>]) -> Option<A> {
specs
.iter()
.find(|spec| spec.bindings.iter().any(|binding| binding.matches(key)))
@@ -564,11 +633,13 @@ fn apply_settings(state: &mut AppState) -> Option<SettingsAction> {
}
}
-fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Option<SettingsAction> {
+fn update_settings_state(state: &mut AppState, key: impl Into<AppKey>) -> Option<SettingsAction> {
use crate::app::state::SettingsSection;
+ let key = key.into();
+
match state.settings.section {
- SettingsSection::Theme => match key.code {
+ SettingsSection::Theme => match key.code() {
KeyCode::Up | KeyCode::Char('k') => {
let previous = state.settings.list.selected;
state.settings.list.move_prev();
@@ -596,7 +667,7 @@ fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Option<Settings
_ => {}
},
},
- SettingsSection::Sound => match key.code {
+ SettingsSection::Sound => match key.code() {
KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
state.settings.list.selected = 1 - state.settings.list.selected.min(1);
}
@@ -618,7 +689,7 @@ fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Option<Settings
_ => {}
},
},
- SettingsSection::Toast => match key.code {
+ SettingsSection::Toast => match key.code() {
KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
state.settings.list.selected = 1 - state.settings.list.selected.min(1);
}
@@ -682,9 +753,10 @@ fn apply_global_menu_action(state: &mut AppState, action: GlobalMenuAction) {
}
}
-fn handle_global_menu_key(state: &mut AppState, key: KeyEvent) {
+fn handle_global_menu_key(state: &mut AppState, key: impl Into<AppKey>) {
+ let key = key.into();
let actions = global_menu_actions(state);
- match key.code {
+ match key.code() {
KeyCode::Esc => leave_modal(state),
KeyCode::Up | KeyCode::Char('k') => state.global_menu.move_prev(),
KeyCode::Down | KeyCode::Char('j') => state.global_menu.move_next(actions.len()),
@@ -697,8 +769,9 @@ fn handle_global_menu_key(state: &mut AppState, key: KeyEvent) {
}
}
-fn handle_keybind_help_key(state: &mut AppState, key: KeyEvent) {
- match key.code {
+fn handle_keybind_help_key(state: &mut AppState, key: impl Into<AppKey>) {
+ let key = key.into();
+ match key.code() {
KeyCode::Up | KeyCode::Char('k') => state.scroll_keybind_help(-1),
KeyCode::Down | KeyCode::Char('j') => state.scroll_keybind_help(1),
KeyCode::PageUp => state.scroll_keybind_help(-8),
@@ -712,18 +785,19 @@ fn handle_keybind_help_key(state: &mut AppState, key: KeyEvent) {
fn navigate_custom_command_for_key(
state: &AppState,
- key: &KeyEvent,
+ key: &AppKey,
) -> Option<crate::config::CustomCommandKeybind> {
state
.keybinds
.custom_commands
.iter()
- .find(|binding| key_matches(key, binding.key.0, binding.key.1))
+ .find(|binding| key.matches_binding(binding.key.0, binding.key.1))
.cloned()
}
-fn handle_navigate_reserved_key(state: &mut AppState, key: KeyEvent) -> bool {
- match key.code {
+fn handle_navigate_reserved_key(state: &mut AppState, key: impl Into<AppKey>) -> bool {
+ let key = key.into();
+ match key.code() {
KeyCode::Char('q') => {
state.should_quit = true;
true
@@ -794,10 +868,11 @@ fn handle_navigate_reserved_key(state: &mut AppState, key: KeyEvent) -> bool {
}
#[allow(dead_code)] // exercised in input unit tests; production uses App::handle_navigate_key
-fn handle_navigate_key(state: &mut AppState, key: KeyEvent) {
+fn handle_navigate_key(state: &mut AppState, key: impl Into<AppKey>) {
+ let key = key.into();
state.update_dismissed = true;
- if state.is_prefix(&key) || key.code == KeyCode::Esc {
+ if key.matches_binding(state.prefix_code, state.prefix_mods) || key.code() == KeyCode::Esc {
leave_navigate_mode(state);
return;
}
@@ -834,72 +909,72 @@ enum NavigateAction {
ToggleSidebar,
}
-fn navigate_action_for_key(state: &AppState, key: &KeyEvent) -> Option<NavigateAction> {
+fn navigate_action_for_key(state: &AppState, key: &AppKey) -> Option<NavigateAction> {
let kb = &state.keybinds;
- if key_matches(key, kb.new_workspace.0, kb.new_workspace.1) {
+ if key.matches_binding(kb.new_workspace.0, kb.new_workspace.1) {
return Some(NavigateAction::NewWorkspace);
}
- if key_matches(key, kb.rename_workspace.0, kb.rename_workspace.1) {
+ if key.matches_binding(kb.rename_workspace.0, kb.rename_workspace.1) {
return Some(NavigateAction::RenameWorkspace);
}
- if key_matches(key, kb.close_workspace.0, kb.close_workspace.1) {
+ if key.matches_binding(kb.close_workspace.0, kb.close_workspace.1) {
return Some(NavigateAction::CloseWorkspace);
}
if kb
.previous_workspace
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ .is_some_and(|(code, mods)| key.matches_binding(code, mods))
{
return Some(NavigateAction::PreviousWorkspace);
}
if kb
.next_workspace
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ .is_some_and(|(code, mods)| key.matches_binding(code, mods))
{
return Some(NavigateAction::NextWorkspace);
}
- if key_matches(key, kb.new_tab.0, kb.new_tab.1) {
+ if key.matches_binding(kb.new_tab.0, kb.new_tab.1) {
return Some(NavigateAction::NewTab);
}
if kb
.rename_tab
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ .is_some_and(|(code, mods)| key.matches_binding(code, mods))
{
return Some(NavigateAction::RenameTab);
}
if kb
.previous_tab
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ .is_some_and(|(code, mods)| key.matches_binding(code, mods))
{
return Some(NavigateAction::PreviousTab);
}
if kb
.next_tab
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ .is_some_and(|(code, mods)| key.matches_binding(code, mods))
{
return Some(NavigateAction::NextTab);
}
if kb
.close_tab
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ .is_some_and(|(code, mods)| key.matches_binding(code, mods))
{
return Some(NavigateAction::CloseTab);
}
- if key_matches(key, kb.split_vertical.0, kb.split_vertical.1) {
+ if key.matches_binding(kb.split_vertical.0, kb.split_vertical.1) {
return Some(NavigateAction::SplitVertical);
}
- if key_matches(key, kb.split_horizontal.0, kb.split_horizontal.1) {
+ if key.matches_binding(kb.split_horizontal.0, kb.split_horizontal.1) {
return Some(NavigateAction::SplitHorizontal);
}
- if key_matches(key, kb.close_pane.0, kb.close_pane.1) {
+ if key.matches_binding(kb.close_pane.0, kb.close_pane.1) {
return Some(NavigateAction::ClosePane);
}
- if key_matches(key, kb.fullscreen.0, kb.fullscreen.1) {
+ if key.matches_binding(kb.fullscreen.0, kb.fullscreen.1) {
return Some(NavigateAction::Fullscreen);
}
- if key_matches(key, kb.resize_mode.0, kb.resize_mode.1) {
+ if key.matches_binding(kb.resize_mode.0, kb.resize_mode.1) {
return Some(NavigateAction::EnterResizeMode);
}
- if key_matches(key, kb.toggle_sidebar.0, kb.toggle_sidebar.1) {
+ if key.matches_binding(kb.toggle_sidebar.0, kb.toggle_sidebar.1) {
return Some(NavigateAction::ToggleSidebar);
}
None
@@ -1155,13 +1230,14 @@ fn apply_rename_action(state: &mut AppState, action: ModalAction) {
}
}
-fn handle_rename_key(state: &mut AppState, key: KeyEvent) {
+fn handle_rename_key(state: &mut AppState, key: impl Into<AppKey>) {
+ let key = key.into();
if let Some(action) = modal_action_from_key(&key, RENAME_ACTIONS) {
apply_rename_action(state, action);
return;
}
- match key.code {
+ match key.code() {
KeyCode::Backspace => {
if state.name_input_replace_on_type {
state.name_input.clear();
@@ -1170,25 +1246,23 @@ fn handle_rename_key(state: &mut AppState, key: KeyEvent) {
state.name_input.pop();
}
}
- KeyCode::Char(c) => {
- if state.name_input_replace_on_type {
- state.name_input.clear();
- state.name_input_replace_on_type = false;
+ _ => {
+ if let Some(c) = key.text_char() {
+ if state.name_input_replace_on_type {
+ state.name_input.clear();
+ state.name_input_replace_on_type = false;
+ }
+ state.name_input.push(c);
}
- state.name_input.push(c);
}
- _ => {}
}
}
-fn handle_resize_key(state: &mut AppState, key: KeyEvent) {
- if key.code == KeyCode::Esc
- || key.code == KeyCode::Enter
- || key_matches(
- &key,
- state.keybinds.resize_mode.0,
- state.keybinds.resize_mode.1,
- )
+fn handle_resize_key(state: &mut AppState, key: impl Into<AppKey>) {
+ let key = key.into();
+ if key.code() == KeyCode::Esc
+ || key.code() == KeyCode::Enter
+ || key.matches_binding(state.keybinds.resize_mode.0, state.keybinds.resize_mode.1)
{
if state.active.is_some() {
state.mode = Mode::Terminal;
@@ -1198,7 +1272,7 @@ fn handle_resize_key(state: &mut AppState, key: KeyEvent) {
return;
}
- match key.code {
+ match key.code() {
KeyCode::Char('h') | KeyCode::Left => state.resize_pane(NavDirection::Left),
KeyCode::Char('l') | KeyCode::Right => state.resize_pane(NavDirection::Right),
KeyCode::Char('j') | KeyCode::Down => state.resize_pane(NavDirection::Down),
@@ -1224,7 +1298,8 @@ fn confirm_close_cancel(state: &mut AppState) {
state.mode = Mode::Navigate;
}
-fn handle_confirm_close_key(state: &mut AppState, key: KeyEvent) {
+fn handle_confirm_close_key(state: &mut AppState, key: impl Into<AppKey>) {
+ let key = key.into();
match modal_action_from_key(&key, CONFIRM_CLOSE_ACTIONS) {
Some(ModalAction::Confirm) => confirm_close_accept(state),
Some(ModalAction::Cancel) => confirm_close_cancel(state),
@@ -1294,8 +1369,9 @@ fn apply_context_menu_action(state: &mut AppState, menu: ContextMenuState, idx:
}
}
-fn handle_context_menu_key(state: &mut AppState, key: KeyEvent) {
- match key.code {
+fn handle_context_menu_key(state: &mut AppState, key: impl Into<AppKey>) {
+ let key = key.into();
+ match key.code() {
KeyCode::Esc => {
state.context_menu = None;
leave_modal(state);
@@ -3494,7 +3570,7 @@ mod tests {
let action = terminal_direct_navigation_action(
&state,
- &KeyEvent::new(KeyCode::Left, KeyModifiers::ALT),
+ &AppKey::from(KeyEvent::new(KeyCode::Left, KeyModifiers::ALT)),
);
assert_eq!(action, Some(NavigateAction::FocusPaneLeft));
@@ -3739,6 +3815,19 @@ mod tests {
}
#[test]
+ fn shifted_slash_terminal_key_opens_keybind_help_from_navigate() {
+ let mut state = state_with_workspaces(&["test"]);
+
+ handle_navigate_key(
+ &mut state,
+ TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT)
+ .with_shifted_codepoint('?' as u32),
+ );
+
+ assert_eq!(state.mode, Mode::KeybindHelp);
+ }
+
+ #[test]
fn rename_modal_keyboard_and_mouse_share_actions() {
let mut state = state_with_workspaces(&["test"]);
state.mode = Mode::RenameWorkspace;
@@ -3828,6 +3917,25 @@ mod tests {
}
#[test]
+ fn rename_modal_uses_shifted_terminal_text_for_app_input() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.mode = Mode::RenameTab;
+
+ handle_rename_key(
+ &mut state,
+ TerminalKey::new(KeyCode::Char('l'), KeyModifiers::SHIFT)
+ .with_shifted_codepoint('L' as u32),
+ );
+ handle_rename_key(
+ &mut state,
+ TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT)
+ .with_shifted_codepoint('?' as u32),
+ );
+
+ assert_eq!(state.name_input, "L?");
+ }
+
+ #[test]
fn open_rename_active_tab_can_prefill_default_new_tab_name() {
let mut state = state_with_workspaces(&["test"]);
state.workspaces[0].test_add_tab(None);
diff --git a/src/app/state.rs b/src/app/state.rs
index 00968c15..b1e69098 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -657,10 +657,6 @@ impl AppState {
self.sound.enabled
}
- pub fn is_prefix(&self, key: &crossterm::event::KeyEvent) -> bool {
- key_matches(key, self.prefix_code, self.prefix_mods)
- }
-
pub fn estimate_pane_size(&self) -> (u16, u16) {
if let Some(info) = self.view.pane_infos.first() {
(info.rect.height, info.rect.width)
@@ -670,6 +666,7 @@ impl AppState {
}
}
+#[cfg(test)]
pub fn key_matches(
key: &crossterm::event::KeyEvent,
expected_code: KeyCode,
diff --git a/src/config.rs b/src/config.rs
index 0e841b0d..7a317204 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -531,6 +531,7 @@ impl Config {
binding: (KeyCode, KeyModifiers),
field: &str,
) -> Option<String> {
+ let binding = crate::input::normalize_app_key_binding(binding.0, binding.1, None);
self.seen
.insert((scope, binding.0, binding.1), field.to_string())
}
@@ -540,6 +541,7 @@ impl Config {
scope: BindingScope,
binding: (KeyCode, KeyModifiers),
) -> Option<&str> {
+ let binding = crate::input::normalize_app_key_binding(binding.0, binding.1, None);
self.seen
.get(&(scope, binding.0, binding.1))
.map(String::as_str)
@@ -551,6 +553,7 @@ impl Config {
binding: (KeyCode, KeyModifiers),
field: &str,
) {
+ let binding = crate::input::normalize_app_key_binding(binding.0, binding.1, None);
self.seen
.entry((scope, binding.0, binding.1))
.or_insert_with(|| field.to_string());
@@ -1655,6 +1658,52 @@ rename_tab = "g"
}
#[test]
+ fn duplicate_shifted_symbol_binding_is_rejected_after_normalization() {
+ let toml = r#"
+[keys]
+new_workspace = "?"
+rename_workspace = "shift+/"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ let diagnostics = config.collect_diagnostics();
+ let kb = config.keybinds();
+
+ assert!(diagnostics.iter().any(|d| {
+ d.contains("duplicate keybinding")
+ && d.contains("keys.rename_workspace")
+ && d.contains("keys.new_workspace")
+ }));
+ assert_eq!(
+ kb.new_workspace,
+ (KeyCode::Char('?'), KeyModifiers::empty())
+ );
+ assert_eq!(
+ kb.rename_workspace,
+ (KeyCode::Char('n'), KeyModifiers::SHIFT)
+ );
+ assert_eq!(kb.rename_workspace_label, "shift+n");
+ }
+
+ #[test]
+ fn custom_command_conflicting_with_shifted_symbol_reserved_key_is_disabled() {
+ let toml = r#"
+[[keys.command]]
+key = "shift+/"
+command = "echo hi"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ let diagnostics = config.collect_diagnostics();
+ let kb = config.keybinds();
+
+ assert!(diagnostics.iter().any(|d| {
+ d.contains("duplicate custom keybinding")
+ && d.contains("keys.command[0].key")
+ && d.contains("navigate.keybind_help")
+ }));
+ assert!(kb.custom_commands.is_empty());
+ }
+
+ #[test]
fn custom_command_keybinds_parse_from_toml() {
let toml = r#"
[[keys.command]]
diff --git a/src/input/mod.rs b/src/input/mod.rs
index 109fd312..67e8a14d 100644
--- a/src/input/mod.rs
+++ b/src/input/mod.rs
@@ -8,7 +8,10 @@ pub use encode::encode_key;
pub use encode::{
encode_cursor_key, encode_mouse_button, encode_mouse_scroll, encode_terminal_key,
};
-pub use model::{KeyboardProtocol, MouseProtocolEncoding, MouseProtocolMode, TerminalKey};
+pub use model::{
+ normalize_app_key_binding, KeyboardProtocol, MouseProtocolEncoding, MouseProtocolMode,
+ TerminalKey,
+};
pub use parse::parse_terminal_key_sequence;
#[cfg(test)]
diff --git a/src/input/model.rs b/src/input/model.rs
index 9339b965..5721f82b 100644
--- a/src/input/model.rs
+++ b/src/input/model.rs
@@ -1,5 +1,57 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+pub fn normalize_app_key_binding(
+ code: KeyCode,
+ modifiers: KeyModifiers,
+ shifted_codepoint: Option<u32>,
+) -> (KeyCode, KeyModifiers) {
+ let KeyCode::Char(raw_char) = code else {
+ return (code, modifiers);
+ };
+
+ let shifted_char = shifted_codepoint
+ .and_then(char::from_u32)
+ .or_else(|| modifiers.contains(KeyModifiers::SHIFT).then_some(raw_char))
+ .and_then(shifted_printable_char);
+
+ let normalized_char = shifted_char.unwrap_or(raw_char);
+ let mut normalized_modifiers = modifiers;
+ if shifted_char.is_some() {
+ normalized_modifiers.remove(KeyModifiers::SHIFT);
+ }
+
+ (KeyCode::Char(normalized_char), normalized_modifiers)
+}
+
+fn shifted_printable_char(ch: char) -> Option<char> {
+ match ch {
+ 'a'..='z' => Some(ch.to_ascii_uppercase()),
+ 'A'..='Z' => Some(ch),
+ '`' | '~' => Some('~'),
+ '1' | '!' => Some('!'),
+ '2' | '@' => Some('@'),
+ '3' | '#' => Some('#'),
+ '4' | '$' => Some('$'),
+ '5' | '%' => Some('%'),
+ '6' | '^' => Some('^'),
+ '7' | '&' => Some('&'),
+ '8' | '*' => Some('*'),
+ '9' | '(' => Some('('),
+ '0' | ')' => Some(')'),
+ '-' | '_' => Some('_'),
+ '=' | '+' => Some('+'),
+ '[' | '{' => Some('{'),
+ ']' | '}' => Some('}'),
+ '\\' | '|' => Some('|'),
+ ';' | ':' => Some(':'),
+ '\'' | '"' => Some('"'),
+ ',' | '<' => Some('<'),
+ '.' | '>' => Some('>'),
+ '/' | '?' => Some('?'),
+ _ => None,
+ }
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TerminalKey {
pub code: KeyCode,