From 8968a0a4a8e80761ae6c8dab9b912530f619479c Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Wed, 3 Jun 2026 02:39:12 +0300 Subject: fix: support legacy shifted letter keybindings refs #312 --- src/app/input/navigate.rs | 82 ++++++++++++++++++++++++++++++++++++++++++++ src/config/keybinds.rs | 86 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 165 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index b6edefaf..4bd69f1c 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -1569,6 +1569,88 @@ last_pane = "prefix+tab" assert_eq!(app.state.mode, Mode::Terminal); } + #[tokio::test] + async fn navigate_mode_matches_legacy_uppercase_shifted_letter() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Navigate; + + app.handle_navigate_key(TerminalKey::new(KeyCode::Char('N'), KeyModifiers::empty())); + + assert!(app.state.request_new_workspace); + assert_eq!(app.state.mode, Mode::Terminal); + } + + #[tokio::test] + async fn legacy_uppercase_prefers_shifted_workspace_binding_over_unshifted() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Navigate; + + app.handle_navigate_key(TerminalKey::new(KeyCode::Char('W'), KeyModifiers::empty())); + + assert_eq!(app.state.mode, Mode::RenameWorkspace); + } + + #[tokio::test] + async fn legacy_uppercase_prefers_shifted_reload_binding_over_unshifted() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Navigate; + + app.handle_navigate_key(TerminalKey::new(KeyCode::Char('R'), KeyModifiers::empty())); + + assert!(app.state.request_reload_config); + assert_eq!(app.state.mode, Mode::Terminal); + } + + #[tokio::test] + async fn legacy_uppercase_prefers_shifted_pane_binding_over_unshifted() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Navigate; + + app.handle_navigate_key(TerminalKey::new(KeyCode::Char('P'), KeyModifiers::empty())); + + assert_eq!(app.state.mode, Mode::RenamePane); + } + #[tokio::test] async fn prefix_focus_pane_is_one_shot_and_returns_to_terminal() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index 6ae8c4a1..299af6cb 100644 --- a/src/config/keybinds.rs +++ b/src/config/keybinds.rs @@ -1075,7 +1075,13 @@ fn key_parts_match_combo( let (expected_code, expected_modifiers) = normalize_key_combo(combo); if actual_modifiers == expected_modifiers - && key_codes_match(actual_code, expected_code, shifted_codepoint) + && key_codes_match( + actual_code, + actual_modifiers, + expected_code, + expected_modifiers, + shifted_codepoint, + ) { return true; } @@ -1084,14 +1090,29 @@ fn key_parts_match_combo( actual_modifiers.contains(KeyModifiers::SHIFT) && actual_without_shift == expected_modifiers && shifted_char_matches_expected(actual_code, shifted_codepoint, expected_code) + || legacy_shifted_ascii_letter_matches( + actual_code, + actual_modifiers, + expected_code, + expected_modifiers, + ) } -fn key_codes_match(actual: KeyCode, expected: KeyCode, shifted_codepoint: Option) -> bool { +fn key_codes_match( + actual: KeyCode, + actual_modifiers: KeyModifiers, + expected: KeyCode, + expected_modifiers: KeyModifiers, + shifted_codepoint: Option, +) -> bool { match (actual, expected) { (KeyCode::Char(actual), KeyCode::Char(expected)) if actual.is_ascii_alphabetic() && expected.is_ascii_alphabetic() => { - actual.eq_ignore_ascii_case(&expected) + actual == expected + || actual_modifiers.contains(KeyModifiers::SHIFT) + && expected_modifiers.contains(KeyModifiers::SHIFT) + && actual.eq_ignore_ascii_case(&expected) } (KeyCode::Char(actual), KeyCode::Char(expected)) => { actual == expected @@ -1105,6 +1126,24 @@ fn key_codes_match(actual: KeyCode, expected: KeyCode, shifted_codepoint: Option } } +fn legacy_shifted_ascii_letter_matches( + actual_code: KeyCode, + actual_modifiers: KeyModifiers, + expected_code: KeyCode, + expected_modifiers: KeyModifiers, +) -> bool { + if actual_modifiers.contains(KeyModifiers::SHIFT) { + return false; + } + let (KeyCode::Char(actual), KeyCode::Char(expected)) = (actual_code, expected_code) else { + return false; + }; + actual.is_ascii_uppercase() + && expected.is_ascii_lowercase() + && actual.to_ascii_lowercase() == expected + && actual_modifiers | KeyModifiers::SHIFT == expected_modifiers +} + fn shifted_char_matches_expected( actual_code: KeyCode, shifted_codepoint: Option, @@ -1345,6 +1384,47 @@ close_tab = "X" assert!(bindings.matches_prefix(&KeyEvent::new(KeyCode::Char('N'), KeyModifiers::SHIFT))); } + #[test] + fn shifted_letter_binding_matches_legacy_uppercase_key_event() { + let bindings = ActionKeybinds::prefix("shift+n"); + assert!(bindings + .matches_prefix_key(TerminalKey::new(KeyCode::Char('N'), KeyModifiers::empty(),))); + } + + #[test] + fn shifted_letter_direct_binding_matches_legacy_uppercase_key_event() { + let bindings = ActionKeybinds::direct("shift+n"); + assert!(bindings + .matches_direct_key(TerminalKey::new(KeyCode::Char('N'), KeyModifiers::empty(),))); + } + + #[test] + fn shifted_letter_binding_matches_modern_modified_key_event() { + let bindings = ActionKeybinds::direct("cmd+shift+j"); + assert!(bindings.matches_direct_key(TerminalKey::new( + KeyCode::Char('J'), + KeyModifiers::SUPER | KeyModifiers::SHIFT, + ))); + } + + #[test] + fn legacy_uppercase_key_event_does_not_match_unshifted_letter_binding() { + let bindings = ActionKeybinds::prefix("n"); + assert!(!bindings + .matches_prefix_key(TerminalKey::new(KeyCode::Char('N'), KeyModifiers::empty(),))); + } + + #[test] + fn legacy_uppercase_shift_fallback_is_limited_to_ascii_letters() { + let shifted_number = ActionKeybinds::prefix("shift+1"); + assert!(!shifted_number + .matches_prefix_key(TerminalKey::new(KeyCode::Char('!'), KeyModifiers::empty(),))); + + let shifted_non_ascii = ActionKeybinds::prefix("shift+ö"); + assert!(!shifted_non_ascii + .matches_prefix_key(TerminalKey::new(KeyCode::Char('Ö'), KeyModifiers::empty(),))); + } + #[test] fn shifted_tab_inputs_match_backtab_canonical_binding() { let bindings = ActionKeybinds::prefix("shift+tab"); -- cgit v1.2.3