From 9f8b51a067a4f38bd3016f5ebeac4908c33aff38 Mon Sep 17 00:00:00 2001 From: Michael Greene Date: Mon, 8 Jun 2026 08:54:39 -0500 Subject: [PATCH 1/6] Ctrl+Space key event translation for Windows --- crates/edit/src/sys/windows.rs | 154 ++++++++++++++++++++++++++++++++- 1 file changed, 150 insertions(+), 4 deletions(-) diff --git a/crates/edit/src/sys/windows.rs b/crates/edit/src/sys/windows.rs index f4a36e82eb7..baf33418bef 100644 --- a/crates/edit/src/sys/windows.rs +++ b/crates/edit/src/sys/windows.rs @@ -259,6 +259,51 @@ fn get_console_size() -> Option { } } +/// Maps a raw Win32 `KEY_EVENT_RECORD` to the UTF-16 code unit to +/// inject, or `None` to skip the event. +/// +/// `Ctrl+Space` reaches the console in three forms: conhost sends +/// (vk=Space, char=0); Windows Terminal/ConPTY sends (vk=Space, +/// char=0x20, a literal space); and the `Ctrl+Shift+2` alias sends +/// (vk=2, char=0). All three normalize to `NUL` so downstream keymaps +/// see `vk::NULL`. It reads only its arguments — no console or global +/// state — so it can be unit-tested directly. +fn translate_key_event( + unicode_char: u16, + virtual_key_code: u16, + control_key_state: u32, +) -> Option { + const VK_SPACE: u16 = 0x20; + const VK_2: u16 = 0x32; + const CTRL_DOWN: u32 = Console::LEFT_CTRL_PRESSED | Console::RIGHT_CTRL_PRESSED; + + let ctrl_pressed = (control_key_state & CTRL_DOWN) != 0; + let shift_pressed = (control_key_state & Console::SHIFT_PRESSED) != 0; + let alt_pressed = + (control_key_state & (Console::LEFT_ALT_PRESSED | Console::RIGHT_ALT_PRESSED)) != 0; + + // AltGr surfaces as Ctrl+Alt on Windows, so a real Ctrl chord is + // "Ctrl down and Alt not down". Without this, AltGr+Space (and any + // AltGr combo the layout maps onto these keys) would be swallowed + // as NUL instead of producing its intended character. + let ctrl_chord = ctrl_pressed && !alt_pressed; + + // Match the Ctrl-bearing patterns before the `unicode_char != 0` + // fallback, so a ConPTY Ctrl+Space (char=0x20) yields NUL rather + // than a literal space. + if ctrl_chord && virtual_key_code == VK_SPACE { + return Some(0); + } + if ctrl_chord && shift_pressed && virtual_key_code == VK_2 && unicode_char == 0 { + return Some(0); + } + + if unicode_char != 0 { + return Some(unicode_char); + } + None +} + /// Reads from stdin. /// /// # Returns @@ -334,10 +379,15 @@ pub fn read_stdin(arena: &Arena, mut timeout: time::Duration) -> Option { let event = unsafe { &inp.Event.KeyEvent }; - let ch = unsafe { event.uChar.UnicodeChar }; - if event.bKeyDown != 0 && ch != 0 { - utf16_buf[utf16_buf_len] = MaybeUninit::new(ch); - utf16_buf_len += 1; + if event.bKeyDown != 0 { + if let Some(ch) = translate_key_event( + unsafe { event.uChar.UnicodeChar }, + event.wVirtualKeyCode, + event.dwControlKeyState, + ) { + utf16_buf[utf16_buf_len] = MaybeUninit::new(ch); + utf16_buf_len += 1; + } } } Console::WINDOW_BUFFER_SIZE_EVENT => { @@ -632,3 +682,99 @@ fn check_bool_return(ret: BOOL) -> io::Result<()> { fn check_ptr_return(ret: *mut T) -> io::Result> { NonNull::new(ret).ok_or_else(last_os_error) } + +#[cfg(test)] +mod tests { + use super::*; + + const VK_SPACE: u16 = 0x20; + const VK_2: u16 = 0x32; + const VK_SHIFT: u16 = 0x10; + const VK_UP: u16 = 0x26; + const VK_A: u16 = 0x41; + const VK_TAB: u16 = 0x09; + + #[test] + fn translate_key_event_passes_through_real_chars() { + // Tab (UnicodeChar = 0x09). + assert_eq!(translate_key_event(0x09, VK_TAB, 0), Some(0x09)); + // 'a'. + assert_eq!(translate_key_event(0x61, VK_A, 0), Some(0x61)); + // Ctrl+A (UnicodeChar = 0x01 — the Win32 driver does the math + // for us for the alphabetic range). + assert_eq!( + translate_key_event(0x01, VK_A, Console::LEFT_CTRL_PRESSED), + Some(0x01) + ); + // Plain Space (no Ctrl) MUST pass through as 0x20 — typing a + // space character is the dominant case. + assert_eq!(translate_key_event(0x20, VK_SPACE, 0), Some(0x20)); + // AltGr is reported as Right-Alt + Left-Ctrl, so AltGr-produced + // characters must pass through rather than be read as a Ctrl + // chord and synthesized to NUL. + let altgr = Console::RIGHT_ALT_PRESSED | Console::LEFT_CTRL_PRESSED; + assert_eq!(translate_key_event(0x20, VK_SPACE, altgr), Some(0x20)); + assert_eq!(translate_key_event(0x40, VK_2, altgr), Some(0x40)); + } + + #[test] + fn translate_key_event_drops_keys_with_no_char() { + // Arrow Up: UnicodeChar=0, handled by the VT stream once + // ENABLE_VIRTUAL_TERMINAL_INPUT generates the escape sequence + // separately. + assert_eq!(translate_key_event(0, VK_UP, 0), None); + // Shift alone: modifier-only keypress. + assert_eq!(translate_key_event(0, VK_SHIFT, 0), None); + } + + #[test] + fn translate_key_event_synthesizes_nul_for_ctrl_space_form1() { + // Form 1: conhost / older WT delivers Ctrl+Space as + // (vk=VK_SPACE, unicode=0). + assert_eq!( + translate_key_event(0, VK_SPACE, Console::LEFT_CTRL_PRESSED), + Some(0) + ); + assert_eq!( + translate_key_event(0, VK_SPACE, Console::RIGHT_CTRL_PRESSED), + Some(0) + ); + } + + #[test] + fn translate_key_event_synthesizes_nul_for_ctrl_space_form2() { + // Form 2: Windows Terminal in ConPTY mode delivers Ctrl+Space + // as (vk=VK_SPACE, unicode=0x20) — a literal space char that + // must still normalize to NUL rather than passing through. + assert_eq!( + translate_key_event(0x20, VK_SPACE, Console::LEFT_CTRL_PRESSED), + Some(0) + ); + assert_eq!( + translate_key_event(0x20, VK_SPACE, Console::RIGHT_CTRL_PRESSED), + Some(0) + ); + } + + #[test] + fn translate_key_event_synthesizes_nul_for_ctrl_shift_2_alias() { + // Form 3: the Ctrl+Shift+2 ASCII alias arrives as + // (vk=VK_2, cks=Ctrl+Shift, unicode=0) because Ctrl+Shift+2 + // == Ctrl+@ == NUL == Ctrl+Space at the byte level. + assert_eq!( + translate_key_event( + 0, + VK_2, + Console::LEFT_CTRL_PRESSED | Console::SHIFT_PRESSED + ), + Some(0) + ); + // Plain "2" key must NOT trigger. + assert_eq!(translate_key_event(0x32, VK_2, 0), Some(0x32)); + // Just Ctrl+2 (no Shift) must NOT trigger. + assert_eq!( + translate_key_event(0x32, VK_2, Console::LEFT_CTRL_PRESSED), + Some(0x32) + ); + } +} From e16ad727296a90371542388256aa264d02cec4fd Mon Sep 17 00:00:00 2001 From: Michael Greene Date: Mon, 8 Jun 2026 09:19:00 -0500 Subject: [PATCH 2/6] Refactor virtual-key code constants for consistency in key event translation --- crates/edit/src/sys/windows.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/edit/src/sys/windows.rs b/crates/edit/src/sys/windows.rs index baf33418bef..77975172c48 100644 --- a/crates/edit/src/sys/windows.rs +++ b/crates/edit/src/sys/windows.rs @@ -259,6 +259,11 @@ fn get_console_size() -> Option { } } +/// Virtual-key codes shared between `translate_key_event` and its +/// tests. Kept here so the two never drift. +const VK_SPACE: u16 = 0x20; +const VK_2: u16 = 0x32; + /// Maps a raw Win32 `KEY_EVENT_RECORD` to the UTF-16 code unit to /// inject, or `None` to skip the event. /// @@ -273,8 +278,6 @@ fn translate_key_event( virtual_key_code: u16, control_key_state: u32, ) -> Option { - const VK_SPACE: u16 = 0x20; - const VK_2: u16 = 0x32; const CTRL_DOWN: u32 = Console::LEFT_CTRL_PRESSED | Console::RIGHT_CTRL_PRESSED; let ctrl_pressed = (control_key_state & CTRL_DOWN) != 0; @@ -687,8 +690,6 @@ fn check_ptr_return(ret: *mut T) -> io::Result> { mod tests { use super::*; - const VK_SPACE: u16 = 0x20; - const VK_2: u16 = 0x32; const VK_SHIFT: u16 = 0x10; const VK_UP: u16 = 0x26; const VK_A: u16 = 0x41; From a74ef74902a87e0fd00c8ab38003565d53930d35 Mon Sep 17 00:00:00 2001 From: Michael Greene Date: Mon, 8 Jun 2026 09:24:39 -0500 Subject: [PATCH 3/6] Enhance Ctrl+Space handling to allow printable characters from IME --- crates/edit/src/sys/windows.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/edit/src/sys/windows.rs b/crates/edit/src/sys/windows.rs index 77975172c48..166ec19e2b6 100644 --- a/crates/edit/src/sys/windows.rs +++ b/crates/edit/src/sys/windows.rs @@ -287,14 +287,20 @@ fn translate_key_event( // AltGr surfaces as Ctrl+Alt on Windows, so a real Ctrl chord is // "Ctrl down and Alt not down". Without this, AltGr+Space (and any - // AltGr combo the layout maps onto these keys) would be swallowed - // as NUL instead of producing its intended character. + // AltGr combo the layout maps onto these keys) would be turned into + // NUL instead of producing its intended character. let ctrl_chord = ctrl_pressed && !alt_pressed; // Match the Ctrl-bearing patterns before the `unicode_char != 0` // fallback, so a ConPTY Ctrl+Space (char=0x20) yields NUL rather - // than a literal space. - if ctrl_chord && virtual_key_code == VK_SPACE { + // than a literal space. Restrict to the two documented Ctrl+Space + // payloads (no char, or a literal space) so a real printable + // character produced on the Space key by an IME or layout still + // passes through as that character. + if ctrl_chord + && virtual_key_code == VK_SPACE + && (unicode_char == 0 || unicode_char == 0x20) + { return Some(0); } if ctrl_chord && shift_pressed && virtual_key_code == VK_2 && unicode_char == 0 { @@ -716,6 +722,12 @@ mod tests { let altgr = Console::RIGHT_ALT_PRESSED | Console::LEFT_CTRL_PRESSED; assert_eq!(translate_key_event(0x20, VK_SPACE, altgr), Some(0x20)); assert_eq!(translate_key_event(0x40, VK_2, altgr), Some(0x40)); + // Ctrl + Space key carrying a real printable char (e.g. from an + // IME) must pass through, not be synthesized to NUL. + assert_eq!( + translate_key_event(0x41, VK_SPACE, Console::LEFT_CTRL_PRESSED), + Some(0x41) + ); } #[test] From e3d2e37ccbeea56aad82e9c88215d45222b60974 Mon Sep 17 00:00:00 2001 From: Michael Greene Date: Thu, 9 Jul 2026 11:08:24 -0500 Subject: [PATCH 4/6] Document Windows Ctrl+Space terminal behavior --- README.md | 6 ++++++ crates/edit/src/sys/windows.rs | 14 ++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b62a023e5d4..8351771d033 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,12 @@ You can install the latest version with WinGet: winget install Microsoft.Edit ``` +#### Ctrl+Space in Windows terminals + +Edit binds `Ctrl+Space` as a keyboard command when the terminal delivers that key event to the application. Windows Terminal and ConPTY commonly pass it through as a Space key event carrying either `NUL` or a literal space; the classic console host commonly passes it as a Space key event carrying `NUL`. Edit normalizes those forms to the same command input. + +Some Windows input-language or IME configurations reserve `Ctrl+Space` for switching input methods. In those setups, Edit can only handle the binding if the terminal/input stack passes the key event through. If an IME produces text on the Space key, Edit preserves that text input instead of treating it as the command binding. + ### Linux (build from source) If your distribution does not provide binaries, or if you'd like to build your own, you can use our install script, provided you have installed: diff --git a/crates/edit/src/sys/windows.rs b/crates/edit/src/sys/windows.rs index 166ec19e2b6..4561c544ca7 100644 --- a/crates/edit/src/sys/windows.rs +++ b/crates/edit/src/sys/windows.rs @@ -722,12 +722,22 @@ mod tests { let altgr = Console::RIGHT_ALT_PRESSED | Console::LEFT_CTRL_PRESSED; assert_eq!(translate_key_event(0x20, VK_SPACE, altgr), Some(0x20)); assert_eq!(translate_key_event(0x40, VK_2, altgr), Some(0x40)); - // Ctrl + Space key carrying a real printable char (e.g. from an - // IME) must pass through, not be synthesized to NUL. + } + + #[test] + fn translate_key_event_passes_through_ime_chars_on_space() { + // Some IME/input-language setups use Ctrl+Space before Edit ever + // sees it. If the terminal does deliver a Space key event with a + // real produced character, keep that text input instead of treating + // it as the editor command binding. assert_eq!( translate_key_event(0x41, VK_SPACE, Console::LEFT_CTRL_PRESSED), Some(0x41) ); + assert_eq!( + translate_key_event(0x3042, VK_SPACE, Console::LEFT_CTRL_PRESSED), + Some(0x3042) + ); } #[test] From 35dbd33e5e51b0af6b8cb8016f280ea4e5e132fd Mon Sep 17 00:00:00 2001 From: Michael Greene Date: Thu, 9 Jul 2026 11:20:04 -0500 Subject: [PATCH 5/6] Apply rustfmt to Windows key tests --- crates/edit/src/sys/windows.rs | 51 +++++++--------------------------- 1 file changed, 10 insertions(+), 41 deletions(-) diff --git a/crates/edit/src/sys/windows.rs b/crates/edit/src/sys/windows.rs index 4561c544ca7..7123218fb46 100644 --- a/crates/edit/src/sys/windows.rs +++ b/crates/edit/src/sys/windows.rs @@ -297,10 +297,7 @@ fn translate_key_event( // payloads (no char, or a literal space) so a real printable // character produced on the Space key by an IME or layout still // passes through as that character. - if ctrl_chord - && virtual_key_code == VK_SPACE - && (unicode_char == 0 || unicode_char == 0x20) - { + if ctrl_chord && virtual_key_code == VK_SPACE && (unicode_char == 0 || unicode_char == 0x20) { return Some(0); } if ctrl_chord && shift_pressed && virtual_key_code == VK_2 && unicode_char == 0 { @@ -709,10 +706,7 @@ mod tests { assert_eq!(translate_key_event(0x61, VK_A, 0), Some(0x61)); // Ctrl+A (UnicodeChar = 0x01 — the Win32 driver does the math // for us for the alphabetic range). - assert_eq!( - translate_key_event(0x01, VK_A, Console::LEFT_CTRL_PRESSED), - Some(0x01) - ); + assert_eq!(translate_key_event(0x01, VK_A, Console::LEFT_CTRL_PRESSED), Some(0x01)); // Plain Space (no Ctrl) MUST pass through as 0x20 — typing a // space character is the dominant case. assert_eq!(translate_key_event(0x20, VK_SPACE, 0), Some(0x20)); @@ -730,14 +724,8 @@ mod tests { // sees it. If the terminal does deliver a Space key event with a // real produced character, keep that text input instead of treating // it as the editor command binding. - assert_eq!( - translate_key_event(0x41, VK_SPACE, Console::LEFT_CTRL_PRESSED), - Some(0x41) - ); - assert_eq!( - translate_key_event(0x3042, VK_SPACE, Console::LEFT_CTRL_PRESSED), - Some(0x3042) - ); + assert_eq!(translate_key_event(0x41, VK_SPACE, Console::LEFT_CTRL_PRESSED), Some(0x41)); + assert_eq!(translate_key_event(0x3042, VK_SPACE, Console::LEFT_CTRL_PRESSED), Some(0x3042)); } #[test] @@ -754,14 +742,8 @@ mod tests { fn translate_key_event_synthesizes_nul_for_ctrl_space_form1() { // Form 1: conhost / older WT delivers Ctrl+Space as // (vk=VK_SPACE, unicode=0). - assert_eq!( - translate_key_event(0, VK_SPACE, Console::LEFT_CTRL_PRESSED), - Some(0) - ); - assert_eq!( - translate_key_event(0, VK_SPACE, Console::RIGHT_CTRL_PRESSED), - Some(0) - ); + assert_eq!(translate_key_event(0, VK_SPACE, Console::LEFT_CTRL_PRESSED), Some(0)); + assert_eq!(translate_key_event(0, VK_SPACE, Console::RIGHT_CTRL_PRESSED), Some(0)); } #[test] @@ -769,14 +751,8 @@ mod tests { // Form 2: Windows Terminal in ConPTY mode delivers Ctrl+Space // as (vk=VK_SPACE, unicode=0x20) — a literal space char that // must still normalize to NUL rather than passing through. - assert_eq!( - translate_key_event(0x20, VK_SPACE, Console::LEFT_CTRL_PRESSED), - Some(0) - ); - assert_eq!( - translate_key_event(0x20, VK_SPACE, Console::RIGHT_CTRL_PRESSED), - Some(0) - ); + assert_eq!(translate_key_event(0x20, VK_SPACE, Console::LEFT_CTRL_PRESSED), Some(0)); + assert_eq!(translate_key_event(0x20, VK_SPACE, Console::RIGHT_CTRL_PRESSED), Some(0)); } #[test] @@ -785,19 +761,12 @@ mod tests { // (vk=VK_2, cks=Ctrl+Shift, unicode=0) because Ctrl+Shift+2 // == Ctrl+@ == NUL == Ctrl+Space at the byte level. assert_eq!( - translate_key_event( - 0, - VK_2, - Console::LEFT_CTRL_PRESSED | Console::SHIFT_PRESSED - ), + translate_key_event(0, VK_2, Console::LEFT_CTRL_PRESSED | Console::SHIFT_PRESSED), Some(0) ); // Plain "2" key must NOT trigger. assert_eq!(translate_key_event(0x32, VK_2, 0), Some(0x32)); // Just Ctrl+2 (no Shift) must NOT trigger. - assert_eq!( - translate_key_event(0x32, VK_2, Console::LEFT_CTRL_PRESSED), - Some(0x32) - ); + assert_eq!(translate_key_event(0x32, VK_2, Console::LEFT_CTRL_PRESSED), Some(0x32)); } } From fb2f85a2d8f66ecbcbdc96155c6351d36f04a42e Mon Sep 17 00:00:00 2001 From: Michael Greene Date: Thu, 9 Jul 2026 11:48:11 -0500 Subject: [PATCH 6/6] Satisfy clippy for Windows input path --- crates/edit/src/sys/windows.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/edit/src/sys/windows.rs b/crates/edit/src/sys/windows.rs index 7123218fb46..959de7a4b0b 100644 --- a/crates/edit/src/sys/windows.rs +++ b/crates/edit/src/sys/windows.rs @@ -385,15 +385,15 @@ pub fn read_stdin(arena: &Arena, mut timeout: time::Duration) -> Option { let event = unsafe { &inp.Event.KeyEvent }; - if event.bKeyDown != 0 { - if let Some(ch) = translate_key_event( + if event.bKeyDown != 0 + && let Some(ch) = translate_key_event( unsafe { event.uChar.UnicodeChar }, event.wVirtualKeyCode, event.dwControlKeyState, - ) { - utf16_buf[utf16_buf_len] = MaybeUninit::new(ch); - utf16_buf_len += 1; - } + ) + { + utf16_buf[utf16_buf_len] = MaybeUninit::new(ch); + utf16_buf_len += 1; } } Console::WINDOW_BUFFER_SIZE_EVENT => {