diff options
| author | akbash <akbash@herdr.dev> | 2026-09-12 00:38:57 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-12 01:38:57 +0400 |
| commit | 9ad65d9031e8cb16a7b553c0e6f74809e9811e92 (patch) | |
| tree | 84fb346571442899f6fe1b1a6b60783d2be764b2 /src | |
| parent | fb5525fc2234a246dfdfc9e0d7ed73c824cbd8a6 (diff) | |
fix: suppress Windows dead-key fallback characters in remote panes (#3972)
refs #3948
Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/client/input/windows_vti.rs | 25 | ||||
| -rw-r--r-- | src/input/encode.rs | 36 | ||||
| -rw-r--r-- | src/input/model.rs | 54 | ||||
| -rw-r--r-- | src/protocol/wire.rs | 49 |
4 files changed, 154 insertions, 10 deletions
diff --git a/src/client/input/windows_vti.rs b/src/client/input/windows_vti.rs index d89a02c7..679d5b31 100644 --- a/src/client/input/windows_vti.rs +++ b/src/client/input/windows_vti.rs @@ -2546,6 +2546,31 @@ mod tests { } #[test] + fn vti_altgr_dead_key_preserves_native_record_without_command_modifiers() { + // AltGr+4 press captured in #3948, Spanish ISO layout. + let record = WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 52, + virtual_scan_code: 5, + unicode: 0, + control_key_state: 9, + }; + let events = translate_with_provenance(win32_input_mode_encoded_record(record)); + assert_eq!( + events, + vec![crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('4'), + modifiers: 0, + kind: crate::protocol::ClientKeyKind::Press, + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::WindowsConsole { record }, + }] + ); + } + + #[test] fn vti_us_international_dead_key_only_emits_composed_text() { fn encode_for_kitty(events: Vec<crate::protocol::ClientInputEvent>, flags: u16) -> Vec<u8> { events diff --git a/src/input/encode.rs b/src/input/encode.rs index 1dd56351..27e59dce 100644 --- a/src/input/encode.rs +++ b/src/input/encode.rs @@ -16,10 +16,10 @@ pub fn encode_key(key: KeyEvent, protocol: KeyboardProtocol) -> Vec<u8> { } pub fn encode_terminal_key(key: TerminalKey, protocol: KeyboardProtocol) -> Vec<u8> { - // A zero Unicode value on this Windows character event means the host layout is - // still composing a dead key. Kitty panes must not receive its physical fallback. + // The host layout has not committed text for this Windows dead key. Neither + // legacy nor Kitty panes should receive its physical character fallback. // Legacy Windows panes take the native ConPTY fallback before reaching this encoder. - if matches!(protocol, KeyboardProtocol::Kitty { .. }) && key.is_windows_shift_dead_key() { + if key.is_windows_dead_key() { return Vec::new(); } @@ -572,6 +572,36 @@ mod tests { } #[test] + fn kitty_all_keys_does_not_encode_windows_altgr_dead_key_phases() { + use crossterm::event::KeyEventKind; + + let key = TerminalKey::new(KeyCode::Char('4'), KeyModifiers::empty()).with_windows_record( + crate::input::WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 52, + virtual_scan_code: 5, + unicode: 0, + control_key_state: 9, + }, + ); + for kind in [ + KeyEventKind::Press, + KeyEventKind::Repeat, + KeyEventKind::Release, + ] { + assert!( + encode_terminal_key( + key.clone().with_kind(kind), + KeyboardProtocol::Kitty { flags: 31 }, + ) + .is_empty(), + "{kind:?}" + ); + } + } + + #[test] fn legacy_enter() { let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()); assert_eq!(encode_key(key, KeyboardProtocol::Legacy), vec![b'\r']); diff --git a/src/input/model.rs b/src/input/model.rs index 50cd7e42..3828174f 100644 --- a/src/input/model.rs +++ b/src/input/model.rs @@ -78,7 +78,7 @@ pub struct TerminalKey { pub shifted_codepoint: Option<u32>, pub generated_text: Option<String>, physical_identity_hint: bool, - windows_shift_dead_key: bool, + windows_dead_key: bool, source: KeySource, } @@ -92,7 +92,7 @@ impl TerminalKey { shifted_codepoint: None, generated_text: None, physical_identity_hint: false, - windows_shift_dead_key: false, + windows_dead_key: false, source: KeySource::Synthesized, } } @@ -160,8 +160,10 @@ impl TerminalKey { mut self, record: Option<WindowsKeyRecord>, ) -> Self { - self.windows_shift_dead_key = matches!(self.code, KeyCode::Char(_)) - && self.modifiers == KeyModifiers::SHIFT + // AltGr is normalized to text-only modifiers by the Windows input mapper. + // Command chords can also have zero Unicode, so retain their fallback keys. + self.windows_dead_key = matches!(self.code, KeyCode::Char(_)) + && self.modifiers.difference(KeyModifiers::SHIFT).is_empty() && record.is_some_and(|record| record.unicode == 0); self } @@ -189,8 +191,8 @@ impl TerminalKey { None } - pub(crate) fn is_windows_shift_dead_key(&self) -> bool { - self.windows_shift_dead_key + pub(crate) fn is_windows_dead_key(&self) -> bool { + self.windows_dead_key } pub(crate) fn identity(&self) -> KeyIdentity { @@ -423,6 +425,46 @@ mod tests { } #[test] + fn windows_composition_hint_requires_uncommitted_text_not_a_command() { + let record = WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 52, + virtual_scan_code: 5, + unicode: 0, + control_key_state: 9, + }; + for modifiers in [ + KeyModifiers::CONTROL, + KeyModifiers::ALT, + KeyModifiers::CONTROL | KeyModifiers::ALT, + KeyModifiers::SUPER, + ] { + let key = TerminalKey::new(KeyCode::Char('4'), modifiers) + .with_windows_composition_hint(Some(record)); + assert!( + !key.is_windows_dead_key(), + "command modifiers: {modifiers:?}" + ); + } + for (code, source) in [ + (KeyCode::Left, Some(record)), + (KeyCode::Char('4'), None), + ( + KeyCode::Char('~'), + Some(WindowsKeyRecord { + unicode: 126, + ..record + }), + ), + ] { + let key = + TerminalKey::new(code, KeyModifiers::empty()).with_windows_composition_hint(source); + assert!(!key.is_windows_dead_key(), "{code:?}, {source:?}"); + } + } + + #[test] fn release_clears_generated_text_and_grouped_repeat_count() { let release = TerminalKey::new(KeyCode::Char('a'), KeyModifiers::empty()) .with_generated_text(Some("a".to_owned())) diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs index 96881b14..052c73a3 100644 --- a/src/protocol/wire.rs +++ b/src/protocol/wire.rs @@ -2066,7 +2066,7 @@ mod tests { else { panic!("pane dead key should remain a key"); }; - assert!(key.is_windows_shift_dead_key()); + assert!(key.is_windows_dead_key()); assert_eq!(key.windows_record(), None); assert!(crate::input::encode_terminal_key( key, @@ -2075,6 +2075,53 @@ mod tests { .is_empty()); } + #[tokio::test] + async fn client_shell_remote_altgr_dead_key_emits_only_composed_text() { + // Spanish ISO AltGr+4, then Space, captured in #3948: + // https://github.com/herdrdev/herdr/issues/3948#issuecomment-5633222390 + let records = [ + ('4', ClientKeyKind::Press, 52, 5, 0, 9), + ('4', ClientKeyKind::Release, 52, 5, 0, 9), + ('~', ClientKeyKind::Press, 32, 57, 126, 0), + (' ', ClientKeyKind::Release, 32, 57, 32, 0), + ]; + let (runtime, _rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24); + let mut output = Vec::new(); + for (ch, kind, virtual_key_code, virtual_scan_code, unicode, control_key_state) in records { + let event = ClientInputEvent::Key { + code: ClientKeyCode::Char(ch), + modifiers: 0, + kind, + repeat_count: 1, + generated_text: None, + source: ClientKeySource::WindowsConsole { + record: crate::input::WindowsKeyRecord { + key_down: kind == ClientKeyKind::Press, + repeat_count: 1, + virtual_key_code, + virtual_scan_code, + unicode, + control_key_state, + }, + }, + }; + let crate::raw_input::RawInputEvent::Key(key) = event.to_raw_input_event() else { + panic!("captured input should remain a key"); + }; + let pane_event = ClientPaneInputEvent::from_terminal_key(key).expect("pane key"); + let crate::raw_input::RawInputEvent::Key(key) = + pane_event.to_raw_input_event_with_windows_source(false) + else { + panic!("remote input should remain a key"); + }; + let bytes = runtime.encode_terminal_key(key); + let expected: &[u8] = if ch == '~' { b"~" } else { b"" }; + assert_eq!(bytes, expected, "captured {ch:?} {kind:?}"); + output.extend(bytes); + } + assert_eq!(output, b"~", "dead key must not insert its base character"); + } + #[test] fn client_shell_key_roundtrip_preserves_physical_generated_text_encoding() { let key = crate::input::TerminalKey::new( |
