Fix chord shortcuts on Windows non-Latin keyboard layouts (#9476)

Fixes #9036.

## Description

On Windows, non-Latin keyboard layouts (Cyrillic, Greek, Arabic, etc.)
translate the physical key to a non-ASCII character even when Ctrl/Cmd
is held. That makes bindings like `ctrl-c` / `ctrl-v` (and the
terminal's copy/paste shortcuts) fail to match — users have to switch
their system layout to English just to copy or paste in Warp.

This change detects that situation in `convert_keyboard_input_event` and
substitutes the US-QWERTY character that the physical key would produce,
so chord shortcuts work regardless of the active OS layout. Same
approach used by VS Code, JetBrains, and Chromium.

The fallback is gated on `cfg(windows)` and on Ctrl/Super being held
with a non-ASCII character — typed text on non-Latin layouts is
unaffected.

## Testing

Unit tests in `key_events_tests.rs` cover the new
`us_qwerty_fallback_for_chord` helper:
- Letter mappings (`KeyA` → `a`, etc.)
- Digit and punctuation mappings
- Returns `None` for keys outside the chord-shortcut set (function keys,
modifiers, navigation), so the original `logical_key` is preserved
- Returns `None` for `PhysicalKey::Unidentified`

Manually verified on Windows:
- [x] Copy / Select All / Paste with English (US) layout
- [x] Copy / Select All / Paste with Russian layout
- [x] Copy / Select All / Paste with German layout
- [x] AltGr (Right Alt) bindings with German layout

## Server API dependencies

No server API changes.

## Agent Mode

- [ ] Warp Agent Mode - This PR was created via Warp's AI Agent Mode

CHANGELOG-BUG-FIX: Fixed Ctrl/Cmd shortcuts (e.g. copy, paste) failing
on Windows when a non-Latin keyboard layout was active.
This commit is contained in:
Landon
2026-05-03 05:13:21 -04:00
committed by GitHub
parent a5fde8f1f6
commit 03ef4d05c8
2 changed files with 224 additions and 2 deletions

View File

@@ -101,6 +101,25 @@ pub fn convert_keyboard_input_event(
{
input.key_without_modifiers()
}
// On Windows, non-Latin keyboard layouts (Cyrillic, Greek, Arabic, etc.) translate
// the physical key to a non-ASCII character even when Ctrl/Cmd is held. That makes
// bindings like `ctrl-c` / `ctrl-v` fail to match. Fall back to the US-QWERTY
// position so chord shortcuts work regardless of the active layout — same approach
// used by VS Code, JetBrains, and Chromium. Issue #9036.
//
// Right-Alt is excluded because Windows reports AltGr as Ctrl+Alt; without this
// guard, AltGr-produced characters (e.g. `€` on a German layout) would be rewritten
// into a spurious chord and the typed character would be swallowed.
#[cfg(windows)]
Key::Character(c)
if (window_state.modifiers.control_key() || window_state.modifiers.super_key())
&& !window_state.right_alt_pressed
&& !c.is_ascii() =>
{
us_qwerty_fallback_for_chord(&input.physical_key, shift)
.map(|s| Key::Character(s.into()))
.unwrap_or_else(|| input.logical_key.clone())
}
_ => input.logical_key,
};
let input_key = get_input_key(&logical_key, shift);
@@ -253,6 +272,100 @@ fn convert_key(key: Key) -> Option<Cow<'static, str>> {
Some(Cow::Borrowed(value))
}
/// Maps a winit `PhysicalKey` to the US-QWERTY character it would produce. Used on Windows
/// to recover layout-independent chord shortcuts (e.g. `ctrl-c`) when a non-Latin keyboard
/// layout has translated the logical key to a non-ASCII character.
///
/// Returns `None` for keys outside the standard letter/digit/punctuation set (function keys,
/// modifiers, navigation keys, etc.), since those either aren't typically used in chord
/// bindings as character keys or are already handled via `NamedKey` in `convert_key`.
#[cfg_attr(not(windows), allow(dead_code))]
fn us_qwerty_fallback_for_chord(
physical_key: &winit::keyboard::PhysicalKey,
shift: bool,
) -> Option<&'static str> {
use winit::keyboard::{KeyCode, PhysicalKey};
let PhysicalKey::Code(code) = physical_key else {
return None;
};
// Letters always return lowercase here; `get_input_key` applies the uppercase
// transform downstream when shift is held. Digit/punctuation keys must return
// the shifted US-QWERTY symbol up front because `get_input_key` passes
// non-letter characters through unchanged, so bindings like `ctrl-shift-}`
// would otherwise see `ctrl-shift-]` on non-Latin layouts.
Some(match (code, shift) {
(KeyCode::KeyA, _) => "a",
(KeyCode::KeyB, _) => "b",
(KeyCode::KeyC, _) => "c",
(KeyCode::KeyD, _) => "d",
(KeyCode::KeyE, _) => "e",
(KeyCode::KeyF, _) => "f",
(KeyCode::KeyG, _) => "g",
(KeyCode::KeyH, _) => "h",
(KeyCode::KeyI, _) => "i",
(KeyCode::KeyJ, _) => "j",
(KeyCode::KeyK, _) => "k",
(KeyCode::KeyL, _) => "l",
(KeyCode::KeyM, _) => "m",
(KeyCode::KeyN, _) => "n",
(KeyCode::KeyO, _) => "o",
(KeyCode::KeyP, _) => "p",
(KeyCode::KeyQ, _) => "q",
(KeyCode::KeyR, _) => "r",
(KeyCode::KeyS, _) => "s",
(KeyCode::KeyT, _) => "t",
(KeyCode::KeyU, _) => "u",
(KeyCode::KeyV, _) => "v",
(KeyCode::KeyW, _) => "w",
(KeyCode::KeyX, _) => "x",
(KeyCode::KeyY, _) => "y",
(KeyCode::KeyZ, _) => "z",
(KeyCode::Digit1, true) => "!",
(KeyCode::Digit1, false) => "1",
(KeyCode::Digit2, true) => "@",
(KeyCode::Digit2, false) => "2",
(KeyCode::Digit3, true) => "#",
(KeyCode::Digit3, false) => "3",
(KeyCode::Digit4, true) => "$",
(KeyCode::Digit4, false) => "4",
(KeyCode::Digit5, true) => "%",
(KeyCode::Digit5, false) => "5",
(KeyCode::Digit6, true) => "^",
(KeyCode::Digit6, false) => "6",
(KeyCode::Digit7, true) => "&",
(KeyCode::Digit7, false) => "7",
(KeyCode::Digit8, true) => "*",
(KeyCode::Digit8, false) => "8",
(KeyCode::Digit9, true) => "(",
(KeyCode::Digit9, false) => "9",
(KeyCode::Digit0, true) => ")",
(KeyCode::Digit0, false) => "0",
(KeyCode::Minus, true) => "_",
(KeyCode::Minus, false) => "-",
(KeyCode::Equal, true) => "+",
(KeyCode::Equal, false) => "=",
(KeyCode::BracketLeft, true) => "{",
(KeyCode::BracketLeft, false) => "[",
(KeyCode::BracketRight, true) => "}",
(KeyCode::BracketRight, false) => "]",
(KeyCode::Backslash, true) => "|",
(KeyCode::Backslash, false) => "\\",
(KeyCode::Semicolon, true) => ":",
(KeyCode::Semicolon, false) => ";",
(KeyCode::Quote, true) => "\"",
(KeyCode::Quote, false) => "'",
(KeyCode::Comma, true) => "<",
(KeyCode::Comma, false) => ",",
(KeyCode::Period, true) => ">",
(KeyCode::Period, false) => ".",
(KeyCode::Slash, true) => "?",
(KeyCode::Slash, false) => "/",
(KeyCode::Backquote, true) => "~",
(KeyCode::Backquote, false) => "`",
_ => return None,
})
}
#[cfg(test)]
#[path = "key_events_tests.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
use super::get_input_key;
use winit::keyboard::{Key::Character, SmolStr};
use super::{get_input_key, us_qwerty_fallback_for_chord};
use winit::keyboard::{Key::Character, KeyCode, NativeKeyCode, PhysicalKey, SmolStr};
#[test]
fn test_get_input_key() {
@@ -48,3 +48,112 @@ fn test_get_input_key() {
}
}
}
#[test]
fn us_qwerty_fallback_maps_letters() {
// Letters return lowercase regardless of shift; `get_input_key` applies the
// uppercase transform downstream.
let cases = [
(KeyCode::KeyA, "a"),
(KeyCode::KeyC, "c"),
(KeyCode::KeyV, "v"),
(KeyCode::KeyZ, "z"),
];
for (code, expected) in cases {
for shift in [false, true] {
assert_eq!(
us_qwerty_fallback_for_chord(&PhysicalKey::Code(code), shift),
Some(expected),
"expected {code:?} -> {expected} (shift={shift})",
);
}
}
}
#[test]
fn us_qwerty_fallback_maps_digits_and_punctuation() {
let cases = [
(KeyCode::Digit0, "0"),
(KeyCode::Digit9, "9"),
(KeyCode::Minus, "-"),
(KeyCode::Equal, "="),
(KeyCode::Slash, "/"),
(KeyCode::Backquote, "`"),
(KeyCode::Semicolon, ";"),
(KeyCode::Comma, ","),
];
for (code, expected) in cases {
assert_eq!(
us_qwerty_fallback_for_chord(&PhysicalKey::Code(code), false),
Some(expected),
"expected {code:?} -> {expected}",
);
}
}
#[test]
fn us_qwerty_fallback_maps_shifted_digits_and_punctuation() {
let cases = [
(KeyCode::Digit1, "!"),
(KeyCode::Digit2, "@"),
(KeyCode::Digit6, "^"),
(KeyCode::Digit9, "("),
(KeyCode::Digit0, ")"),
(KeyCode::Minus, "_"),
(KeyCode::Equal, "+"),
(KeyCode::BracketLeft, "{"),
(KeyCode::BracketRight, "}"),
(KeyCode::Backslash, "|"),
(KeyCode::Semicolon, ":"),
(KeyCode::Quote, "\""),
(KeyCode::Comma, "<"),
(KeyCode::Period, ">"),
(KeyCode::Slash, "?"),
(KeyCode::Backquote, "~"),
];
for (code, expected) in cases {
assert_eq!(
us_qwerty_fallback_for_chord(&PhysicalKey::Code(code), true),
Some(expected),
"expected {code:?} + shift -> {expected}",
);
}
}
#[test]
fn us_qwerty_fallback_returns_none_for_unmapped_keys() {
// Keys outside the chord-shortcut set should fall through so the original
// logical_key is preserved.
let unmapped = [
KeyCode::F1,
KeyCode::F13,
KeyCode::AltLeft,
KeyCode::ShiftRight,
KeyCode::ControlLeft,
KeyCode::Enter,
KeyCode::Escape,
KeyCode::ArrowUp,
KeyCode::Tab,
];
for code in unmapped {
for shift in [false, true] {
assert_eq!(
us_qwerty_fallback_for_chord(&PhysicalKey::Code(code), shift),
None,
"{code:?} should not have a chord fallback (shift={shift})",
);
}
}
}
#[test]
fn us_qwerty_fallback_returns_none_for_unidentified_physical_key() {
let unidentified = PhysicalKey::Unidentified(NativeKeyCode::Unidentified);
for shift in [false, true] {
assert_eq!(
us_qwerty_fallback_for_chord(&unidentified, shift),
None,
"unidentified key should not have a chord fallback (shift={shift})",
);
}
}