From 190eb341da739a6574cdb6991c22a931779d244d Mon Sep 17 00:00:00 2001 From: Raphael Amorim Date: Sat, 3 Oct 2026 22:56:49 +0200 Subject: [PATCH] Resolve Latin keys through real layouts for bindings --- .../rioterm/src/bindings/kitty_keyboard.rs | 55 ++++++++++++++++--- .../src/platform/modifier_supplement.rs | 19 +++++++ .../platform_impl/linux/common/xkb/keymap.rs | 33 +++++++++++ .../src/platform_impl/linux/common/xkb/mod.rs | 5 ++ rio-window/src/platform_impl/linux/mod.rs | 1 + .../src/platform_impl/macos/app_delegate.rs | 1 + rio-window/src/platform_impl/macos/event.rs | 49 ++++++++++++++--- rio-window/src/platform_impl/macos/ffi.rs | 2 + .../src/platform_impl/orbital/event_loop.rs | 1 + rio-window/src/platform_impl/orbital/mod.rs | 1 + .../src/platform_impl/windows/keyboard.rs | 1 + rio-window/src/platform_impl/windows/mod.rs | 1 + 12 files changed, 154 insertions(+), 15 deletions(-) diff --git a/frontends/rioterm/src/bindings/kitty_keyboard.rs b/frontends/rioterm/src/bindings/kitty_keyboard.rs index 31351a020e..97cd2a9896 100644 --- a/frontends/rioterm/src/bindings/kitty_keyboard.rs +++ b/frontends/rioterm/src/bindings/kitty_keyboard.rs @@ -495,12 +495,18 @@ fn alternate_keys_payload( /// while a non-Latin layout is active. Plain and shifted keys are text /// input, so they never fall back. pub fn base_layout_binding_key(key: &KeyEvent, mods: ModifiersState) -> Option { - base_layout_fallback_key(&key.logical_key, key.physical_key, mods) + base_layout_fallback_key( + &key.logical_key, + key.physical_key, + key.base_layout_key(), + mods, + ) } fn base_layout_fallback_key( logical_key: &Key, physical_key: PhysicalKey, + layout_resolved: Option, mods: ModifiersState, ) -> Option { if !(mods.control_key() || mods.alt_key() || mods.super_key()) { @@ -519,6 +525,14 @@ fn base_layout_fallback_key( Key::Unidentified(_) => {} _ => return None, } + // Prefer the platform's resolution through the user's real layouts + // (another xkb group, the macOS ASCII-capable layout); fall back to + // the key's position in the standard PC-101 layout. + if let Some(Key::Character(ch)) = layout_resolved { + if ch.is_ascii() && ch.chars().count() == 1 { + return Some(Key::Character(ch.to_lowercase().into())); + } + } let base = char::from_u32(base_layout_key(physical_key)?)?; Some(Key::Character(base.to_string().into())) } @@ -588,26 +602,53 @@ mod tests { use super::*; use rio_window::keyboard::NativeKey; + #[test] + fn base_layout_fallback_prefers_platform_resolution() { + let cyrillic = Key::Character("с".into()); + // Dvorak-style: the layout resolves to a different Latin key than + // the PC-101 position. + let physical = PhysicalKey::Code(KeyCode::KeyC); + assert_eq!( + base_layout_fallback_key( + &cyrillic, + physical, + Some(Key::Character("j".into())), + ModifiersState::CONTROL + ), + Some(Key::Character("j".into())) + ); + // A non-ASCII or multi-char resolution falls back to PC-101. + assert_eq!( + base_layout_fallback_key( + &cyrillic, + physical, + Some(Key::Character("ж".into())), + ModifiersState::CONTROL + ), + Some(Key::Character("c".into())) + ); + } + #[test] fn base_layout_fallback_requires_command_modifier() { let cyrillic = Key::Character("с".into()); let physical = PhysicalKey::Code(KeyCode::KeyC); let base = Key::Character("c".into()); assert_eq!( - base_layout_fallback_key(&cyrillic, physical, ModifiersState::CONTROL), + base_layout_fallback_key(&cyrillic, physical, None, ModifiersState::CONTROL), Some(base.clone()) ); assert_eq!( - base_layout_fallback_key(&cyrillic, physical, ModifiersState::SUPER), + base_layout_fallback_key(&cyrillic, physical, None, ModifiersState::SUPER), Some(base) ); // Plain and shifted typing never falls back. assert_eq!( - base_layout_fallback_key(&cyrillic, physical, ModifiersState::empty()), + base_layout_fallback_key(&cyrillic, physical, None, ModifiersState::empty()), None ); assert_eq!( - base_layout_fallback_key(&cyrillic, physical, ModifiersState::SHIFT), + base_layout_fallback_key(&cyrillic, physical, None, ModifiersState::SHIFT), None ); } @@ -618,7 +659,7 @@ mod tests { let ctrl_alt = ModifiersState::CONTROL | ModifiersState::ALT; let unidentified = Key::Unidentified(NativeKey::Unidentified); assert_eq!( - base_layout_fallback_key(&unidentified, physical, ctrl_alt), + base_layout_fallback_key(&unidentified, physical, None, ctrl_alt), Some(Key::Character("n".into())) ); let typed = Key::Character("ń".into()); @@ -628,7 +669,7 @@ mod tests { Some(Key::Character("n".into())) }; assert_eq!( - base_layout_fallback_key(&typed, physical, ctrl_alt), + base_layout_fallback_key(&typed, physical, None, ctrl_alt), expected ); } diff --git a/rio-window/src/platform/modifier_supplement.rs b/rio-window/src/platform/modifier_supplement.rs index 651ba7f3aa..c64fb5be1e 100644 --- a/rio-window/src/platform/modifier_supplement.rs +++ b/rio-window/src/platform/modifier_supplement.rs @@ -20,6 +20,20 @@ pub trait KeyEventExtModifierSupplement { /// key as `Character` according to the current keyboard layout. This value /// cannot be `Dead`. fn key_without_modifiers(&self) -> Key; + + /// The key resolved against a Latin (ASCII-capable) keyboard layout, + /// ignoring all modifiers. + /// + /// When the active layout cannot produce ASCII for this key (Cyrillic, + /// Greek, Arabic, ...), this resolves the key through a layout that can: + /// another configured xkb layout group on Linux, the ASCII-capable + /// keyboard layout on macOS. Useful for matching shortcuts such as + /// `Ctrl+Shift+V` independently of the active layout. + /// + /// Returns `None` when no configured layout produces ASCII for this key, + /// or on platforms without layout introspection (Windows); callers may + /// then fall back to the key's position in the standard PC-101 layout. + fn base_layout_key(&self) -> Option; } impl KeyEventExtModifierSupplement for KeyEvent { @@ -35,4 +49,9 @@ impl KeyEventExtModifierSupplement for KeyEvent { fn key_without_modifiers(&self) -> Key { self.platform_specific.key_without_modifiers.clone() } + + #[inline] + fn base_layout_key(&self) -> Option { + self.platform_specific.base_layout_key.clone() + } } diff --git a/rio-window/src/platform_impl/linux/common/xkb/keymap.rs b/rio-window/src/platform_impl/linux/common/xkb/keymap.rs index 708b967c58..30681a51dc 100644 --- a/rio-window/src/platform_impl/linux/common/xkb/keymap.rs +++ b/rio-window/src/platform_impl/linux/common/xkb/keymap.rs @@ -1031,6 +1031,39 @@ impl XkbKeymap { pub fn key_repeats(&mut self, keycode: xkb_keycode_t) -> bool { unsafe { (XKBH.xkb_keymap_key_repeats)(self.keymap.as_ptr(), keycode) == 1 } } + + /// The key resolved against the first layout group that produces ASCII + /// for this key, checking `active_layout` first. This mirrors xkb's + /// group fallback for shortcuts: a Cyrillic layout resolves through the + /// Latin group configured next to it. `None` when no group produces an + /// ASCII character. + pub fn base_layout_key( + &mut self, + keycode: xkb_keycode_t, + active_layout: xkb_layout_index_t, + ) -> Option { + let num_layouts = unsafe { + (XKBH.xkb_keymap_num_layouts_for_key)(self.keymap.as_ptr(), keycode) + }; + let layouts = std::iter::once(active_layout) + .chain((0..num_layouts).filter(|&layout| layout != active_layout)); + for layout in layouts { + let keysym = self.first_keysym_by_level(layout, keycode); + if keysym == 0 { + continue; + } + let codepoint = unsafe { (XKBH.xkb_keysym_to_utf32)(keysym) }; + let Some(ch) = char::from_u32(codepoint) else { + continue; + }; + if ch.is_ascii_graphic() { + return Some(Key::Character(smol_str::SmolStr::new( + ch.to_ascii_lowercase().to_string(), + ))); + } + } + None + } } impl Drop for XkbKeymap { diff --git a/rio-window/src/platform_impl/linux/common/xkb/mod.rs b/rio-window/src/platform_impl/linux/common/xkb/mod.rs index ddb5c20b59..0e86402d73 100644 --- a/rio-window/src/platform_impl/linux/common/xkb/mod.rs +++ b/rio-window/src/platform_impl/linux/common/xkb/mod.rs @@ -210,6 +210,10 @@ impl<'a> KeyContext<'a> { state: ElementState, repeat: bool, ) -> KeyEvent { + let base_layout_key = { + let active_layout = self.state.layout(keycode); + self.keymap.base_layout_key(keycode, active_layout) + }; let mut event = KeyEventResults::new( self, keycode, @@ -224,6 +228,7 @@ impl<'a> KeyContext<'a> { let platform_specific = KeyEventExtra { text_with_all_modifiers, key_without_modifiers, + base_layout_key, }; KeyEvent { diff --git a/rio-window/src/platform_impl/linux/mod.rs b/rio-window/src/platform_impl/linux/mod.rs index 4b057141ab..8dc85cf366 100644 --- a/rio-window/src/platform_impl/linux/mod.rs +++ b/rio-window/src/platform_impl/linux/mod.rs @@ -641,6 +641,7 @@ impl Window { pub struct KeyEventExtra { pub text_with_all_modifiers: Option, pub key_without_modifiers: Key, + pub base_layout_key: Option, } #[derive(Clone, Debug, Eq, Hash, PartialEq)] diff --git a/rio-window/src/platform_impl/macos/app_delegate.rs b/rio-window/src/platform_impl/macos/app_delegate.rs index 201dbfcc1b..5cd926c807 100644 --- a/rio-window/src/platform_impl/macos/app_delegate.rs +++ b/rio-window/src/platform_impl/macos/app_delegate.rs @@ -646,6 +646,7 @@ impl ApplicationDelegate { platform_specific: KeyEventExtra { text_with_all_modifiers: character.map(SmolStr::new), key_without_modifiers: logical_key, + base_layout_key: None, }, } } diff --git a/rio-window/src/platform_impl/macos/event.rs b/rio-window/src/platform_impl/macos/event.rs index 0135707928..2603103bbd 100644 --- a/rio-window/src/platform_impl/macos/event.rs +++ b/rio-window/src/platform_impl/macos/event.rs @@ -18,21 +18,44 @@ use crate::platform_impl::platform::ffi; pub struct KeyEventExtra { pub text_with_all_modifiers: Option, pub key_without_modifiers: Key, + pub base_layout_key: Option, } /// Ignores ALL modifiers. pub fn get_modifierless_char(scancode: u16) -> Key { + let input_source = unsafe { ffi::TISCopyCurrentKeyboardLayoutInputSource() }; + if input_source.is_null() { + tracing::error!("`TISCopyCurrentKeyboardLayoutInputSource` returned null ptr"); + return Key::Unidentified(NativeKey::MacOS(scancode)); + } + char_for_input_source(scancode, input_source) +} + +/// Like [`get_modifierless_char`] but resolved against the current +/// ASCII-capable keyboard layout, so a Latin key comes back even while a +/// non-Latin layout is active. `None` when the resolution fails or yields +/// no character. +pub fn get_base_layout_char(scancode: u16) -> Option { + let input_source = + unsafe { ffi::TISCopyCurrentASCIICapableKeyboardLayoutInputSource() }; + if input_source.is_null() { + tracing::error!( + "`TISCopyCurrentASCIICapableKeyboardLayoutInputSource` returned null ptr" + ); + return None; + } + match char_for_input_source(scancode, input_source) { + key @ Key::Character(_) => Some(key), + _ => None, + } +} + +/// Translate `scancode` with no modifiers through `input_source`, releasing +/// the source before returning. +fn char_for_input_source(scancode: u16, input_source: ffi::TISInputSourceRef) -> Key { let mut string = [0; 16]; - let input_source; let layout; unsafe { - input_source = ffi::TISCopyCurrentKeyboardLayoutInputSource(); - if input_source.is_null() { - tracing::error!( - "`TISCopyCurrentKeyboardLayoutInputSource` returned null ptr" - ); - return Key::Unidentified(NativeKey::MacOS(scancode)); - } let layout_data = ffi::TISGetInputSourceProperty( input_source, ffi::kTISPropertyUnicodeKeyLayoutData, @@ -174,6 +197,15 @@ pub(crate) fn create_key_event( (key_from_code.clone(), key_from_code) }; + // Named keys are layout-independent; character keys on an ASCII-capable + // layout already carry their Latin key, so the extra translation is only + // needed when the active layout produced a non-ASCII character. + let base_layout_key = match &key_without_modifiers { + Key::Character(ch) if ch.is_ascii() => Some(key_without_modifiers.clone()), + Key::Character(_) | Key::Unidentified(_) => get_base_layout_char(scancode), + _ => None, + }; + let text = if is_press { logical_key.to_text().map(SmolStr::new) } else { @@ -192,6 +224,7 @@ pub(crate) fn create_key_event( platform_specific: KeyEventExtra { text_with_all_modifiers, key_without_modifiers, + base_layout_key, }, } } diff --git a/rio-window/src/platform_impl/macos/ffi.rs b/rio-window/src/platform_impl/macos/ffi.rs index 9d25960dd7..30cf3c50f8 100644 --- a/rio-window/src/platform_impl/macos/ffi.rs +++ b/rio-window/src/platform_impl/macos/ffi.rs @@ -196,6 +196,8 @@ extern "C" { pub fn TISCopyCurrentKeyboardLayoutInputSource() -> TISInputSourceRef; + pub fn TISCopyCurrentASCIICapableKeyboardLayoutInputSource() -> TISInputSourceRef; + pub fn LMGetKbdType() -> u8; #[allow(non_snake_case)] diff --git a/rio-window/src/platform_impl/orbital/event_loop.rs b/rio-window/src/platform_impl/orbital/event_loop.rs index 62ce9c902e..a5bbe3c0f8 100644 --- a/rio-window/src/platform_impl/orbital/event_loop.rs +++ b/rio-window/src/platform_impl/orbital/event_loop.rs @@ -440,6 +440,7 @@ impl EventLoop { platform_specific: KeyEventExtra { key_without_modifiers, text_with_all_modifiers, + base_layout_key: None, }, }, is_synthetic: false, diff --git a/rio-window/src/platform_impl/orbital/mod.rs b/rio-window/src/platform_impl/orbital/mod.rs index fb0d829f0b..72803824be 100644 --- a/rio-window/src/platform_impl/orbital/mod.rs +++ b/rio-window/src/platform_impl/orbital/mod.rs @@ -271,4 +271,5 @@ impl VideoModeHandle { pub struct KeyEventExtra { pub key_without_modifiers: Key, pub text_with_all_modifiers: Option, + pub base_layout_key: Option, } diff --git a/rio-window/src/platform_impl/windows/keyboard.rs b/rio-window/src/platform_impl/windows/keyboard.rs index 16e83d4a14..6603be0896 100644 --- a/rio-window/src/platform_impl/windows/keyboard.rs +++ b/rio-window/src/platform_impl/windows/keyboard.rs @@ -689,6 +689,7 @@ impl PartialKeyEventInfo { platform_specific: KeyEventExtra { text_with_all_modifiers: char_with_all_modifiers, key_without_modifiers: self.key_without_modifiers, + base_layout_key: None, }, } } diff --git a/rio-window/src/platform_impl/windows/mod.rs b/rio-window/src/platform_impl/windows/mod.rs index 1a1e1b92fe..4951c2e97d 100644 --- a/rio-window/src/platform_impl/windows/mod.rs +++ b/rio-window/src/platform_impl/windows/mod.rs @@ -97,6 +97,7 @@ pub type OsError = std::io::Error; pub struct KeyEventExtra { pub text_with_all_modifiers: Option, pub key_without_modifiers: Key, + pub base_layout_key: Option, } #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]