diff --git a/crates/warpui/src/windowing/winit/event_loop/key_events.rs b/crates/warpui/src/windowing/winit/event_loop/key_events.rs index 168d9e0e92..83006e1a47 100644 --- a/crates/warpui/src/windowing/winit/event_loop/key_events.rs +++ b/crates/warpui/src/windowing/winit/event_loop/key_events.rs @@ -99,7 +99,22 @@ pub fn convert_keyboard_input_event( .modifiers .contains(ModifiersState::CONTROL | ModifiersState::ALT) => { - input.key_without_modifiers() + let kwm = input.key_without_modifiers(); + if !matches!(kwm, Key::Unidentified(_)) { + kwm + } else if let Some(text) = input.text_with_all_modifiers() { + Key::Character(text.to_lowercase().into()) + } else { + kwm + } + } + // RDP Unicode input mode (Windows App for iPad): both physical_key and logical_key + // are Unidentified because the RDP client sends Unicode events without scancodes. + // The only reliable source of the typed character is input.text. We synthesize a + // Key::Character from it so the rest of the pipeline can handle it normally. + #[cfg(windows)] + Key::Unidentified(NativeKey::Windows(_)) if input.text.is_some() => { + rdp_key_from_text(input.text.as_deref().unwrap()) } // 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 @@ -153,12 +168,33 @@ pub fn convert_keyboard_input_event( }) } +/// Synthesizes a `Key::Character` from raw text in RDP Unicode input mode. +/// +/// In RDP Unicode mode (Windows App for iPad) both `physical_key` and `logical_key` +/// are `Unidentified`; `input.text` is the only source of the typed character. +/// We lowercase the text so that `get_input_key` can re-apply case based on the +/// current `shift` state, matching the behaviour of normal Scancode-mode input. +#[cfg(windows)] +pub(super) fn rdp_key_from_text(text: &str) -> Key { + Key::Character(text.to_lowercase().into()) +} + #[cfg(not(target_family = "wasm"))] /// Returns the base key without any modifiers applied, or `None` if it cannot be determined. fn get_key_without_modifiers(input: &winit::event::KeyEvent) -> Option { let unmodified = input.key_without_modifiers(); let unmodified_input = get_input_key(&unmodified, false); - convert_key(unmodified_input).map(|k| k.to_string()) + if let Some(key) = convert_key(unmodified_input).map(|k| k.to_string()) { + return Some(key); + } + // Fallback for RDP Unicode mode: both physical_key and logical_key are Unidentified, + // so key_without_modifiers() also returns Unidentified. Use input.text lowercased + // as a best-effort base key (e.g. Shift+A sends text="A" → base "a"). + #[cfg(windows)] + if let Some(text) = &input.text { + return convert_key(rdp_key_from_text(text)).map(|k| k.to_string()); + } + None } #[cfg(target_family = "wasm")] @@ -211,7 +247,7 @@ fn get_input_key(logical_key: &Key, is_shift: bool) -> Key { /// Converts a winit [`winit::keyboard::Key`] to the corresponding string version /// expected by the UI framework. -fn convert_key(key: Key) -> Option> { +pub(super) fn convert_key(key: Key) -> Option> { use winit::keyboard::Key::*; let value = match key { diff --git a/crates/warpui/src/windowing/winit/event_loop/key_events_tests.rs b/crates/warpui/src/windowing/winit/event_loop/key_events_tests.rs index 149791070b..246d1032a1 100644 --- a/crates/warpui/src/windowing/winit/event_loop/key_events_tests.rs +++ b/crates/warpui/src/windowing/winit/event_loop/key_events_tests.rs @@ -1,5 +1,7 @@ -use super::{get_input_key, us_qwerty_fallback_for_chord}; -use winit::keyboard::{Key::Character, KeyCode, NativeKeyCode, PhysicalKey, SmolStr}; +use super::{convert_key, get_input_key, us_qwerty_fallback_for_chord}; +#[cfg(windows)] +use super::rdp_key_from_text; +use winit::keyboard::{Key, Key::Character, KeyCode, NativeKeyCode, PhysicalKey, SmolStr}; #[test] fn test_get_input_key() { @@ -157,3 +159,73 @@ fn us_qwerty_fallback_returns_none_for_unidentified_physical_key() { ); } } + +// Tests for RDP Unicode mode fallback: exercises the Unidentified+text path +// via rdp_key_from_text, which is the exact function called when both +// physical_key and logical_key are Unidentified(Windows(_)) and text is Some. +#[cfg(windows)] +#[test] +fn rdp_key_from_text_produces_lowercase_character() { + // Verify that rdp_key_from_text lowercases the input so that get_input_key + // can re-apply case from the shift modifier state downstream. + let cases = [ + ("h", "h"), + ("H", "h"), // Shift held — text arrives as uppercase, must be lowercased + ("k", "k"), + ("1", "1"), + ("!", "!"), // punctuation — no case change + ]; + for (text, expected) in cases { + let key = rdp_key_from_text(text); + let result = convert_key(key); + assert_eq!( + result.as_deref(), + Some(expected), + "rdp_key_from_text({text:?}) should produce key '{expected}'", + ); + } +} + +#[cfg(windows)] +#[test] +fn rdp_key_from_text_roundtrips_through_get_input_key_without_shift() { + // Without shift, get_input_key should lowercase — same as rdp_key_from_text output. + let key = rdp_key_from_text("h"); + let input_key = get_input_key(&key, false); + let result = convert_key(input_key); + assert_eq!(result.as_deref(), Some("h")); +} + +#[cfg(windows)] +#[test] +fn rdp_key_from_text_roundtrips_through_get_input_key_with_shift() { + // With shift held, get_input_key should uppercase the lowercased character, + // producing the correct final key for shift+letter chords. + let key = rdp_key_from_text("H"); // Shift+H arrives as "H" in text + let input_key = get_input_key(&key, true); // shift=true + let result = convert_key(input_key); + assert_eq!(result.as_deref(), Some("H")); +} + +#[test] +fn rdp_unicode_mode_named_keys_still_convert() { + // Named keys (Enter, Escape, arrows) go through convert_key via the Named + // variant and must work regardless of physical_key being Unidentified. + use winit::keyboard::NamedKey; + let cases = [ + (Key::Named(NamedKey::Enter), "enter"), + (Key::Named(NamedKey::Escape), "escape"), + (Key::Named(NamedKey::ArrowUp), "up"), + (Key::Named(NamedKey::ArrowDown), "down"), + (Key::Named(NamedKey::Tab), "tab"), + (Key::Named(NamedKey::Backspace), "backspace"), + ]; + for (key, expected) in cases { + let result = convert_key(key.clone()); + assert_eq!( + result.as_deref(), + Some(expected), + "convert_key({key:?}) should return '{expected}'", + ); + } +} diff --git a/crates/warpui/src/windowing/winit/event_loop/mod.rs b/crates/warpui/src/windowing/winit/event_loop/mod.rs index 3d9d64da61..03299a525a 100644 --- a/crates/warpui/src/windowing/winit/event_loop/mod.rs +++ b/crates/warpui/src/windowing/winit/event_loop/mod.rs @@ -106,6 +106,29 @@ fn try_from_winit_keycode(keycode: &KeyCode) -> Result Result { + use crate::platform::keyboard::KeyCode; + match vk as u32 { + 0xA0 => Ok(KeyCode::ShiftLeft), + 0xA1 => Ok(KeyCode::ShiftRight), + 0xA2 => Ok(KeyCode::ControlLeft), + 0xA3 => Ok(KeyCode::ControlRight), + 0xA4 => Ok(KeyCode::AltLeft), + 0xA5 => Ok(KeyCode::AltRight), + 0x5B => Ok(KeyCode::SuperLeft), + 0x5C => Ok(KeyCode::SuperRight), + // Generic (non-sided) VK codes — map to left variant as safe fallback + 0x10 => Ok(KeyCode::ShiftLeft), + 0x11 => Ok(KeyCode::ControlLeft), + 0x12 => Ok(KeyCode::AltLeft), + _ => Err(()), + } +} + /// Data needed to detect double/triple-click. struct MouseButtonPressState { pressed_at: Instant, @@ -1283,6 +1306,19 @@ impl EventLoop { _ => {} } } + // Fallback for RDP Unicode mode: physical_key is Unidentified, so use + // the Windows VK code directly to track left/right Alt state. + #[cfg(windows)] + if let keyboard::PhysicalKey::Unidentified(keyboard::NativeKeyCode::Windows(vk)) = + &event.physical_key + { + let is_pressed = event.state == ElementState::Pressed; + match *vk as u32 { + 0xA4 => window_state.left_alt_pressed = is_pressed, + 0xA5 => window_state.right_alt_pressed = is_pressed, + _ => {} + } + } // If the event is a modifier key, just by itself, we handle it specially, issuing // the appropriate Warp-side event (ModifierKeyChanged). @@ -1296,6 +1332,19 @@ impl EventLoop { }); } } + // Fallback for RDP Unicode mode: emit ModifierKeyChanged using Windows VK + // codes when physical_key is Unidentified and text is None (pure modifier key). + #[cfg(windows)] + if let (None, keyboard::PhysicalKey::Unidentified(keyboard::NativeKeyCode::Windows(vk))) = + (&event.text, &event.physical_key) + { + if let Ok(mapped_keycode) = try_from_windows_vk(*vk) { + return Some(ConvertedEvent::ModifierKeyChanged { + key_code: mapped_keycode, + state: event.state, + }); + } + } let event_text = event.text.as_ref().map(|text| text.to_string()); let warp_ui_event = @@ -2043,6 +2092,41 @@ enum ConvertedEvent { }, } +#[cfg(test)] +#[cfg(windows)] +mod rdp_vk_tests { + use super::try_from_windows_vk; + use crate::platform::keyboard::KeyCode; + + #[test] + fn sided_modifier_vk_codes() { + assert_eq!(try_from_windows_vk(0xA0), Ok(KeyCode::ShiftLeft)); + assert_eq!(try_from_windows_vk(0xA1), Ok(KeyCode::ShiftRight)); + assert_eq!(try_from_windows_vk(0xA2), Ok(KeyCode::ControlLeft)); + assert_eq!(try_from_windows_vk(0xA3), Ok(KeyCode::ControlRight)); + assert_eq!(try_from_windows_vk(0xA4), Ok(KeyCode::AltLeft)); + assert_eq!(try_from_windows_vk(0xA5), Ok(KeyCode::AltRight)); + assert_eq!(try_from_windows_vk(0x5B), Ok(KeyCode::SuperLeft)); + assert_eq!(try_from_windows_vk(0x5C), Ok(KeyCode::SuperRight)); + } + + #[test] + fn generic_modifier_vk_codes_map_to_left_variant() { + assert_eq!(try_from_windows_vk(0x10), Ok(KeyCode::ShiftLeft)); + assert_eq!(try_from_windows_vk(0x11), Ok(KeyCode::ControlLeft)); + assert_eq!(try_from_windows_vk(0x12), Ok(KeyCode::AltLeft)); + } + + #[test] + fn non_modifier_vk_codes_return_err() { + // VK_C (character key), VK_RETURN, VK_ESCAPE + assert_eq!(try_from_windows_vk(0x43), Err(())); + assert_eq!(try_from_windows_vk(0x0D), Err(())); + assert_eq!(try_from_windows_vk(0x1B), Err(())); + assert_eq!(try_from_windows_vk(0x00), Err(())); + } +} + /// Convert the platform-independent trait object Window into a concrete, platform-specific Window. fn downcast_window(window: &dyn platform::Window) -> &super::Window { window