diff options
| author | JJ Liebig <jonathan.liebig@gmail.com> | 2026-09-03 17:20:57 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-03 17:20:57 +0200 |
| commit | 0496636dc75db14a99aa71a7cadb006dc027d5c6 (patch) | |
| tree | 943d44ed4474ccc9e39ee527a44f00365c81dfbb /src | |
| parent | 0f8ad128b65869d683253753a477c0ccafca9bf2 (diff) | |
fix(windows): preserve ctrl+slash key identity (#3578)
refs #2954
Diffstat (limited to 'src')
| -rw-r--r-- | src/client/input/windows_vti.rs | 83 | ||||
| -rw-r--r-- | src/input/encode.rs | 8 | ||||
| -rw-r--r-- | src/platform/windows.rs | 30 |
3 files changed, 106 insertions, 15 deletions
diff --git a/src/client/input/windows_vti.rs b/src/client/input/windows_vti.rs index b877245c..fc81cd81 100644 --- a/src/client/input/windows_vti.rs +++ b/src/client/input/windows_vti.rs @@ -603,7 +603,7 @@ impl WindowsInputMapper { }; } - let events = self.translate_semantic_key_events(key); + let events = self.translate_semantic_key_events(key, resolve_ctrl_oem_char(key)); let items = if let Some(bytes) = self.paste_payload_bytes_for_key(key) { vec![PlatformInputItem::PasteAwareKey { win32_paste_bytes: bytes.clone(), @@ -657,10 +657,11 @@ impl WindowsInputMapper { win32_paste_bytes, }); } else { + let oem_char = resolve_ctrl_oem_char(record); items.push(PlatformInputItem::PasteAwareKey { bytes, win32_paste_bytes, - events: self.translate_semantic_key_events(record), + events: self.translate_semantic_key_events(record, oem_char), }) } } @@ -740,6 +741,7 @@ impl WindowsInputMapper { fn translate_semantic_key_events( &mut self, key: WindowsKeyRecord, + oem_char: Option<char>, ) -> Vec<crate::protocol::ClientInputEvent> { if !self.key_record_can_emit_event(key) { return Vec::new(); @@ -764,8 +766,9 @@ impl WindowsInputMapper { && !is_alt_code && !(0xd800..=0xdfff).contains(&key.unicode); if carries_native_record { + let kind = Self::semantic_key_kind(key, is_alt_code, 0); return self - .translate_semantic_key_event(key, Self::semantic_key_kind(key, is_alt_code, 0)) + .translate_semantic_key_event(key, kind, oem_char) .map(|event| match event { crate::protocol::ClientInputEvent::Key { code, @@ -801,6 +804,7 @@ impl WindowsInputMapper { self.translate_semantic_key_event( key, Self::semantic_key_kind(key, is_alt_code, repeat_idx), + oem_char, ) }) .collect() @@ -842,6 +846,7 @@ impl WindowsInputMapper { &mut self, key: WindowsKeyRecord, kind: crate::protocol::ClientKeyKind, + oem_char: Option<char>, ) -> Option<crate::protocol::ClientInputEvent> { let modifiers = windows_key_modifiers(key.control_key_state); if key.virtual_key_code == 0 { @@ -880,7 +885,7 @@ impl WindowsInputMapper { if modifiers.contains(crossterm::event::KeyModifiers::CONTROL) && !(0x30..=0x39).contains(&key.virtual_key_code) { - if let Some(code) = windows_unicode_control_to_key_code(key.unicode) { + if let Some(code) = ctrl_key_code(key.virtual_key_code, key.unicode, oem_char) { self.pending_high_surrogate = None; return Some(crate::protocol::ClientInputEvent::Key { code, @@ -1200,19 +1205,35 @@ fn windows_virtual_key_to_char_code( Some(ClientKeyCode::Char(ch)) } -fn windows_unicode_control_to_key_code(unicode: u16) -> Option<crate::protocol::ClientKeyCode> { +fn ctrl_key_code(vk: u16, u: u16, oem: Option<char>) -> Option<crate::protocol::ClientKeyCode> { use crate::protocol::ClientKeyCode; - Some(match unicode { - 0x00 => ClientKeyCode::Char(' '), - 0x1b => ClientKeyCode::Char('['), - 0x1c => ClientKeyCode::Char('\\'), - 0x1d => ClientKeyCode::Char(']'), - 0x1e => ClientKeyCode::Char('^'), - 0x1f => ClientKeyCode::Char('-'), + Some(match (vk, u) { + (0xbf, 0x00) => ClientKeyCode::Char(oem?), + (_, 0x00) => ClientKeyCode::Char(' '), + (_, 0x1b) => ClientKeyCode::Char('['), + (_, 0x1c) => ClientKeyCode::Char('\\'), + (_, 0x1d) => ClientKeyCode::Char(']'), + (_, 0x1e) => ClientKeyCode::Char('^'), + (_, 0x1f) => ClientKeyCode::Char('-'), _ => return None, }) } +fn resolve_ctrl_oem_char(key: WindowsKeyRecord) -> Option<char> { + if key.virtual_key_code == 0xbf + && key.unicode == 0 + && windows_key_modifiers(key.control_key_state) + .contains(crossterm::event::KeyModifiers::CONTROL) + { + #[cfg(windows)] + return crate::platform::resolve_base_printable_key( + key.virtual_key_code, + key.virtual_scan_code, + ); + } + None +} + #[cfg(any(windows, test))] fn windows_input_trace_enabled() -> bool { std::env::var_os("HERDR_WINDOWS_INPUT_TRACE").is_some() @@ -1771,6 +1792,44 @@ mod tests { } #[test] + fn vti_ctrl_oem_record_uses_resolved_layout_character() { + let pressed = WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 0xbf, + virtual_scan_code: 0x35, + unicode: 0, + control_key_state: 0x0028, + }; + let released = WindowsKeyRecord { + key_down: false, + ..pressed + }; + let mut mapper = WindowsInputMapper::default(); + let expected = |record, kind, ch| crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char(ch), + modifiers: crossterm::event::KeyModifiers::CONTROL.bits(), + kind, + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::WindowsConsole { record }, + }; + for (record, kind, ch) in [ + (pressed, crate::protocol::ClientKeyKind::Press, '/'), + (released, crate::protocol::ClientKeyKind::Release, '/'), + (pressed, crate::protocol::ClientKeyKind::Press, 'ß'), + ] { + assert_eq!( + mapper.translate_semantic_key_events(record, Some(ch)), + [expected(record, kind, ch)] + ); + } + assert!(mapper + .translate_semantic_key_events(pressed, None) + .is_empty()); + } + + #[test] fn vti_ctrl_bracket_record_flushes_to_escape_after_idle() { let mut translator = WindowsInputTranslator::default(); assert!(translator diff --git a/src/input/encode.rs b/src/input/encode.rs index 6dfff4ce..1dd56351 100644 --- a/src/input/encode.rs +++ b/src/input/encode.rs @@ -501,7 +501,7 @@ fn encode_legacy_inner(key: TerminalKey) -> Vec<u8> { ']' | '5' => vec![29], '^' | '6' => vec![30], '_' | '/' | '7' | '-' => vec![31], - _ => vec![ch as u8], + _ => ch.to_string().into_bytes(), } } else { let ch = if key.modifiers == KeyModifiers::SHIFT { @@ -590,6 +590,12 @@ mod tests { } #[test] + fn legacy_ctrl_non_ascii_char_uses_utf8() { + let key = KeyEvent::new(KeyCode::Char('ß'), KeyModifiers::CONTROL); + assert_eq!(encode_key(key, KeyboardProtocol::Legacy), "ß".as_bytes()); + } + + #[test] fn legacy_shift_enter_is_just_cr() { let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT); assert_eq!(encode_key(key, KeyboardProtocol::Legacy), vec![b'\r']); diff --git a/src/platform/windows.rs b/src/platform/windows.rs index b05b9e6f..890c24ca 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -105,8 +105,8 @@ use windows_sys::{ Input::{ Ime::ImmGetDefaultIMEWnd, KeyboardAndMouse::{ - GetKeyboardLayout, SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, - KEYEVENTF_KEYUP, + GetKeyboardLayout, SendInput, ToUnicodeEx, INPUT, INPUT_0, INPUT_KEYBOARD, + KEYBDINPUT, KEYEVENTF_KEYUP, }, }, Shell::{ @@ -134,6 +134,32 @@ pub(crate) fn terminal_title_for_presentation(title: &str) -> &str { title.strip_prefix("Administrator: ").unwrap_or(title) } +/// Resolves against the current foreground layout because asynchronous console +/// records do not retain the layout that was active when the key was pressed. +pub(crate) fn resolve_base_printable_key(vk: u16, scan: u16) -> Option<char> { + // SAFETY: Win32 owns the handles; the fixed buffers match the API lengths. + unsafe { + let thread_id = GetWindowThreadProcessId(GetForegroundWindow(), null_mut()); + let layout = GetKeyboardLayout(thread_id); + + let key_state = [0u8; 256]; + let mut output = [0u16; 2]; + let written = ToUnicodeEx( + vk.into(), + scan.into(), + key_state.as_ptr(), + output.as_mut_ptr(), + output.len() as i32, + 0x4, + layout, + ); + let units = output.get(..usize::try_from(written).ok()?)?; + let mut chars = char::decode_utf16(units.iter().copied()); + let ch = chars.next()?.ok()?; + (chars.next().is_none() && !ch.is_control()).then_some(ch) + } +} + const MAX_PROCESS_ENVIRONMENT_BYTES: usize = 256 * 1024; const PROCESS_ENVIRONMENT_READ_CHUNK_BYTES: usize = 16 * 1024; const PROCESS_RUNTIME_MARKER_CACHE_CAPACITY: usize = 1_024; |
