From 7d931f40cc5cda27d35254c2ac8c953bb75067d9 Mon Sep 17 00:00:00 2001 From: okhsunrog Date: Thu, 24 Sep 2026 19:13:37 +0300 Subject: [PATCH 1/2] Support non-Latin keyboard layouts in kitty keys and bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a Cyrillic layout active, ctrl+с was reported as ctrl+U+0441 and ctrl+shift+м never matched the ctrl+shift+v paste binding. - Report the kitty base layout key (the key at the same position in the PC-101 layout, derived from the physical key) when an application enables REPORT_ALTERNATE_KEYS, so ctrl+с is sent as `1089::99` and apps can match it as ctrl+c. - When no key binding matches a textual key pressed with Ctrl, Alt or Super, retry with the same PC-101 key. --- .../rioterm/src/bindings/kitty_keyboard.rs | 135 +++++++++++++++++- frontends/rioterm/src/screen/mod.rs | 84 ++++++----- 2 files changed, 181 insertions(+), 38 deletions(-) diff --git a/frontends/rioterm/src/bindings/kitty_keyboard.rs b/frontends/rioterm/src/bindings/kitty_keyboard.rs index b01c05571a..a442d99d11 100644 --- a/frontends/rioterm/src/bindings/kitty_keyboard.rs +++ b/frontends/rioterm/src/bindings/kitty_keyboard.rs @@ -7,6 +7,7 @@ use rio_window::keyboard::Key; use rio_window::keyboard::KeyLocation; use rio_window::keyboard::ModifiersState; use rio_window::keyboard::NamedKey; +use rio_window::keyboard::{KeyCode, PhysicalKey}; use rio_window::platform::modifier_supplement::KeyEventExtModifierSupplement; use std::borrow::Cow; @@ -146,12 +147,16 @@ impl SequenceBuilder { } } - // NOTE: Base layouts are ignored, since winit doesn't expose this information - // yet. - let payload = if self.mode.contains(Mode::REPORT_ALTERNATE_KEYS) - && alternate_key_code != unicode_key_code - { - format!("{unicode_key_code}:{alternate_key_code}") + let payload = if self.mode.contains(Mode::REPORT_ALTERNATE_KEYS) { + let shifted_key_code = (alternate_key_code != unicode_key_code) + .then_some(alternate_key_code); + let base_layout_key_code = base_layout_key(key.physical_key) + .filter(|&code| code != unicode_key_code); + alternate_keys_payload( + unicode_key_code, + shifted_key_code, + base_layout_key_code, + ) } else { unicode_key_code.to_string() }; @@ -464,3 +469,121 @@ fn is_control_character(text: &str) -> bool { let codepoint = text.bytes().next().unwrap(); text.len() == 1 && (codepoint < 0x20 || (0x7f..=0x9f).contains(&codepoint)) } + +/// Format the key code field with its alternate keys: +/// `unicode-key-code[:shifted-key[:base-layout-key]]`. A base layout key +/// without a shifted key keeps an empty sub-field (`1089::99`). +fn alternate_keys_payload( + unicode_key_code: u32, + shifted_key_code: Option, + base_layout_key_code: Option, +) -> String { + match (shifted_key_code, base_layout_key_code) { + (None, None) => unicode_key_code.to_string(), + (Some(shifted), None) => format!("{unicode_key_code}:{shifted}"), + (shifted, Some(base)) => { + let shifted = shifted.map(|code| code.to_string()).unwrap_or_default(); + format!("{unicode_key_code}:{shifted}:{base}") + } + } +} + +/// The PC-101 character of a textual key pressed with a command modifier +/// (Ctrl, Alt or Super), for matching key bindings such as `ctrl+shift+v` +/// 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 { + if !(mods.control_key() || mods.alt_key() || mods.super_key()) { + return None; + } + if !matches!(key.logical_key, Key::Character(_)) { + return None; + } + let base = char::from_u32(base_layout_key(key.physical_key)?)?; + Some(Key::Character(base.to_string().into())) +} + +/// The key at `physical_key`'s position in the standard PC-101 layout, +/// which the kitty keyboard protocol reports as the base layout key. It +/// lets applications match `ctrl+c` while a non-Latin layout is active. +fn base_layout_key(physical_key: PhysicalKey) -> Option { + let PhysicalKey::Code(code) = physical_key else { + return None; + }; + let ch = match code { + KeyCode::KeyA => 'a', + KeyCode::KeyB => 'b', + KeyCode::KeyC => 'c', + KeyCode::KeyD => 'd', + KeyCode::KeyE => 'e', + KeyCode::KeyF => 'f', + KeyCode::KeyG => 'g', + KeyCode::KeyH => 'h', + KeyCode::KeyI => 'i', + KeyCode::KeyJ => 'j', + KeyCode::KeyK => 'k', + KeyCode::KeyL => 'l', + KeyCode::KeyM => 'm', + KeyCode::KeyN => 'n', + KeyCode::KeyO => 'o', + KeyCode::KeyP => 'p', + KeyCode::KeyQ => 'q', + KeyCode::KeyR => 'r', + KeyCode::KeyS => 's', + KeyCode::KeyT => 't', + KeyCode::KeyU => 'u', + KeyCode::KeyV => 'v', + KeyCode::KeyW => 'w', + KeyCode::KeyX => 'x', + KeyCode::KeyY => 'y', + KeyCode::KeyZ => 'z', + KeyCode::Digit0 => '0', + KeyCode::Digit1 => '1', + KeyCode::Digit2 => '2', + KeyCode::Digit3 => '3', + KeyCode::Digit4 => '4', + KeyCode::Digit5 => '5', + KeyCode::Digit6 => '6', + KeyCode::Digit7 => '7', + KeyCode::Digit8 => '8', + KeyCode::Digit9 => '9', + KeyCode::Backquote => '`', + KeyCode::Minus => '-', + KeyCode::Equal => '=', + KeyCode::BracketLeft => '[', + KeyCode::BracketRight => ']', + KeyCode::Backslash => '\\', + KeyCode::Semicolon => ';', + KeyCode::Quote => '\'', + KeyCode::Comma => ',', + KeyCode::Period => '.', + KeyCode::Slash => '/', + _ => return None, + }; + Some(u32::from(ch)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base_layout_key_follows_pc101_position() { + assert_eq!(base_layout_key(PhysicalKey::Code(KeyCode::KeyC)), Some(99)); + assert_eq!(base_layout_key(PhysicalKey::Code(KeyCode::Comma)), Some(44)); + assert_eq!(base_layout_key(PhysicalKey::Code(KeyCode::Enter)), None); + } + + #[test] + fn alternate_keys_payload_fields() { + assert_eq!(alternate_keys_payload(99, None, None), "99"); + assert_eq!(alternate_keys_payload(97, Some(65), None), "97:65"); + // ctrl+С on a Cyrillic layout: `с` (U+0441) on the `c` key. + assert_eq!(alternate_keys_payload(1089, None, Some(99)), "1089::99"); + // shift+С on a Cyrillic layout. + assert_eq!( + alternate_keys_payload(1089, Some(1057), Some(99)), + "1089:1057:99" + ); + } +} diff --git a/frontends/rioterm/src/screen/mod.rs b/frontends/rioterm/src/screen/mod.rs index 1c13ed9fc0..d422b910de 100644 --- a/frontends/rioterm/src/screen/mod.rs +++ b/frontends/rioterm/src/screen/mod.rs @@ -9,7 +9,7 @@ pub mod hint; pub mod touch; -use crate::bindings::kitty_keyboard::build_key_sequence; +use crate::bindings::kitty_keyboard::{base_layout_binding_key, build_key_sequence}; use crate::bindings::{ Action as Act, BindingKey, BindingMode, FontSizeAction, MouseBinding, SearchAction, ViAction, @@ -1046,42 +1046,62 @@ impl Screen<'_> { let binding_mode = BindingMode::new(mode, search_active); let mut ignore_chars = None; + // We don't want the key without modifier, because it means something else most of + // the time. However what we want is to manually lowercase the character to account + // for both small and capital letters on regular characters at the same time. + let logical_key = if let Key::Character(ch) = key.logical_key.as_ref() { + // Match `Alt` bindings without `Alt` being applied, otherwise they use the + // composed chars, which are not intuitive to bind. + // + // On Windows, the `Ctrl + Alt` mangles `logical_key` to unidentified values, thus + // preventing them from being used in bindings + // + // For more see https://github.com/rust-windowing/winit/issues/2945. + // if (cfg!(target_os = "macos") || (cfg!(windows) && mods.control_key())) + // && mods.alt_key() + if (mods.shift_key() || mods.alt_key()) + || mods.alt_key() && (cfg!(windows) && mods.control_key()) + { + key.key_without_modifiers() + } else { + Key::Character(ch.to_lowercase().into()) + } + } else { + key.logical_key.clone() + }; + + let key_match_for = |trigger: &BindingKey, logical_key: Key| match trigger { + BindingKey::Scancode(_) => BindingKey::Scancode(key.physical_key), + _ => BindingKey::Keycode { + key: logical_key, + location: key.location, + }, + }; + + // On a non-Latin layout ctrl+shift+м is ctrl+shift+v on the US layout: when + // nothing matches the layout's character, match the key at the same position. + let logical_key = match base_layout_binding_key(key, mods) { + Some(base_key) + if base_key != logical_key + && !self.bindings.iter().any(|binding| { + binding.is_triggered_by( + binding_mode.to_owned(), + mods, + &key_match_for(&binding.trigger, logical_key.clone()), + ) + }) => + { + base_key + } + _ => logical_key, + }; + for i in 0..self.bindings.len() { let binding = &self.bindings[i]; let trigger = &binding.trigger; let action = binding.action.clone(); - // We don't want the key without modifier, because it means something else most of - // the time. However what we want is to manually lowercase the character to account - // for both small and capital letters on regular characters at the same time. - let logical_key = if let Key::Character(ch) = key.logical_key.as_ref() { - // Match `Alt` bindings without `Alt` being applied, otherwise they use the - // composed chars, which are not intuitive to bind. - // - // On Windows, the `Ctrl + Alt` mangles `logical_key` to unidentified values, thus - // preventing them from being used in bindings - // - // For more see https://github.com/rust-windowing/winit/issues/2945. - // if (cfg!(target_os = "macos") || (cfg!(windows) && mods.control_key())) - // && mods.alt_key() - if (mods.shift_key() || mods.alt_key()) - || mods.alt_key() && (cfg!(windows) && mods.control_key()) - { - key.key_without_modifiers() - } else { - Key::Character(ch.to_lowercase().into()) - } - } else { - key.logical_key.clone() - }; - - let key_match = match (&trigger, logical_key) { - (BindingKey::Scancode(_), _) => BindingKey::Scancode(key.physical_key), - (_, code) => BindingKey::Keycode { - key: code, - location: key.location, - }, - }; + let key_match = key_match_for(trigger, logical_key.clone()); if binding.is_triggered_by(binding_mode.to_owned(), mods, &key_match) { *ignore_chars.get_or_insert(true) &= action != Act::ReceiveChar; From 62bbb10d52b42077a8ff1beb0995df9788a12de4 Mon Sep 17 00:00:00 2001 From: Raphael Amorim Date: Sat, 3 Oct 2026 11:11:08 +0200 Subject: [PATCH 2/2] bindings: guard base layout fallback for AltGr and Unidentified --- .../rioterm/src/bindings/kitty_keyboard.rs | 76 +++++++++++++++++-- frontends/rioterm/src/screen/mod.rs | 16 +--- 2 files changed, 75 insertions(+), 17 deletions(-) diff --git a/frontends/rioterm/src/bindings/kitty_keyboard.rs b/frontends/rioterm/src/bindings/kitty_keyboard.rs index a442d99d11..31351a020e 100644 --- a/frontends/rioterm/src/bindings/kitty_keyboard.rs +++ b/frontends/rioterm/src/bindings/kitty_keyboard.rs @@ -150,8 +150,10 @@ impl SequenceBuilder { let payload = if self.mode.contains(Mode::REPORT_ALTERNATE_KEYS) { let shifted_key_code = (alternate_key_code != unicode_key_code) .then_some(alternate_key_code); - let base_layout_key_code = base_layout_key(key.physical_key) - .filter(|&code| code != unicode_key_code); + let base_layout_key_code = + base_layout_key(key.physical_key).filter(|&code| { + code != unicode_key_code && Some(code) != shifted_key_code + }); alternate_keys_payload( unicode_key_code, shifted_key_code, @@ -493,13 +495,31 @@ 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) +} + +fn base_layout_fallback_key( + logical_key: &Key, + physical_key: PhysicalKey, + mods: ModifiersState, +) -> Option { if !(mods.control_key() || mods.alt_key() || mods.super_key()) { return None; } - if !matches!(key.logical_key, Key::Character(_)) { - return None; + match logical_key { + // AltGr arrives as Ctrl+Alt on Windows; a chord that typed a + // character is AltGr text input, not a shortcut. + Key::Character(_) => { + if cfg!(windows) && mods.control_key() && mods.alt_key() { + return None; + } + } + // Windows mangles Ctrl+Alt chords on some layouts to Unidentified + // (rust-windowing/winit#2945); the physical key is still valid. + Key::Unidentified(_) => {} + _ => return None, } - let base = char::from_u32(base_layout_key(key.physical_key)?)?; + let base = char::from_u32(base_layout_key(physical_key)?)?; Some(Key::Character(base.to_string().into())) } @@ -566,6 +586,52 @@ fn base_layout_key(physical_key: PhysicalKey) -> Option { #[cfg(test)] mod tests { use super::*; + use rio_window::keyboard::NativeKey; + + #[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), + Some(base.clone()) + ); + assert_eq!( + base_layout_fallback_key(&cyrillic, physical, ModifiersState::SUPER), + Some(base) + ); + // Plain and shifted typing never falls back. + assert_eq!( + base_layout_fallback_key(&cyrillic, physical, ModifiersState::empty()), + None + ); + assert_eq!( + base_layout_fallback_key(&cyrillic, physical, ModifiersState::SHIFT), + None + ); + } + + #[test] + fn base_layout_fallback_handles_windows_ctrl_alt() { + let physical = PhysicalKey::Code(KeyCode::KeyN); + let ctrl_alt = ModifiersState::CONTROL | ModifiersState::ALT; + let unidentified = Key::Unidentified(NativeKey::Unidentified); + assert_eq!( + base_layout_fallback_key(&unidentified, physical, ctrl_alt), + Some(Key::Character("n".into())) + ); + let typed = Key::Character("ń".into()); + let expected = if cfg!(windows) { + None + } else { + Some(Key::Character("n".into())) + }; + assert_eq!( + base_layout_fallback_key(&typed, physical, ctrl_alt), + expected + ); + } #[test] fn base_layout_key_follows_pc101_position() { diff --git a/frontends/rioterm/src/screen/mod.rs b/frontends/rioterm/src/screen/mod.rs index d422b910de..0992157b89 100644 --- a/frontends/rioterm/src/screen/mod.rs +++ b/frontends/rioterm/src/screen/mod.rs @@ -1050,18 +1050,10 @@ impl Screen<'_> { // the time. However what we want is to manually lowercase the character to account // for both small and capital letters on regular characters at the same time. let logical_key = if let Key::Character(ch) = key.logical_key.as_ref() { - // Match `Alt` bindings without `Alt` being applied, otherwise they use the - // composed chars, which are not intuitive to bind. - // - // On Windows, the `Ctrl + Alt` mangles `logical_key` to unidentified values, thus - // preventing them from being used in bindings - // - // For more see https://github.com/rust-windowing/winit/issues/2945. - // if (cfg!(target_os = "macos") || (cfg!(windows) && mods.control_key())) - // && mods.alt_key() - if (mods.shift_key() || mods.alt_key()) - || mods.alt_key() && (cfg!(windows) && mods.control_key()) - { + // Match `Shift`/`Alt` bindings without the modifier applied, + // otherwise they use the shifted or composed chars, which are + // not intuitive to bind. + if mods.shift_key() || mods.alt_key() { key.key_without_modifiers() } else { Key::Character(ch.to_lowercase().into())