aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorJJ Liebig <jonathan.liebig@gmail.com>2026-09-03 17:20:57 +0200
committerGitHub <noreply@github.com>2026-09-03 17:20:57 +0200
commit0496636dc75db14a99aa71a7cadb006dc027d5c6 (patch)
tree943d44ed4474ccc9e39ee527a44f00365c81dfbb /src
parent0f8ad128b65869d683253753a477c0ccafca9bf2 (diff)
fix(windows): preserve ctrl+slash key identity (#3578)
refs #2954
Diffstat (limited to 'src')
-rw-r--r--src/client/input/windows_vti.rs83
-rw-r--r--src/input/encode.rs8
-rw-r--r--src/platform/windows.rs30
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;