Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 195 additions & 6 deletions frontends/rioterm/src/bindings/kitty_keyboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -146,12 +147,18 @@ 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 && Some(code) != shifted_key_code
});
alternate_keys_payload(
unicode_key_code,
shifted_key_code,
base_layout_key_code,
)
} else {
unicode_key_code.to_string()
};
Expand Down Expand Up @@ -464,3 +471,185 @@ 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<u32>,
base_layout_key_code: Option<u32>,
) -> 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<Key> {
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<Key> {
if !(mods.control_key() || mods.alt_key() || mods.super_key()) {
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(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<u32> {
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::*;
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() {
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"
);
}
}
76 changes: 44 additions & 32 deletions frontends/rioterm/src/screen/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1046,42 +1046,54 @@ 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 `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())
}
} 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;
Expand Down
Loading