use std::sync::OnceLock;
use ratatui::crossterm::event::{KeyCode, KeyEvent};
pub fn hotkey_char(key: &KeyEvent) -> Option<char> {
match key.code {
KeyCode::Char(c) => Some(physical_key_char(c)),
_ => None,
}
}
fn physical_key_char(c: char) -> char {
let lower = lowercase(c);
if lower.is_ascii() {
return lower;
}
#[cfg(windows)]
if let Some(ch) = win_layout::from_active_layout(lower) {
return ch;
}
if let Some(ch) = jcuken_key(lower) {
return ch;
}
#[cfg(windows)]
if let Some(ch) = win_layout::from_installed_layouts(lower) {
return ch;
}
lower
}
fn lowercase(c: char) -> char {
let mut it = c.to_lowercase();
match (it.next(), it.next()) {
(Some(lower), None) => lower,
_ => c,
}
}
fn jcuken_key(lower: char) -> Option<char> {
Some(match lower {
'й' => 'q',
'ц' => 'w',
'у' => 'e',
'к' => 'r',
'е' => 't',
'н' => 'y',
'г' => 'u',
'ш' => 'i',
'щ' => 'o',
'з' => 'p',
'ф' => 'a',
'ы' => 's',
'в' => 'd',
'а' => 'f',
'п' => 'g',
'р' => 'h',
'о' => 'j',
'л' => 'k',
'д' => 'l',
'я' => 'z',
'ч' => 'x',
'с' => 'c',
'м' => 'v',
'и' => 'b',
'т' => 'n',
'ь' => 'm',
_ => return None,
})
}
#[cfg(test)]
fn physical_char(c: char) -> char {
let lower = lowercase(c);
jcuken_key(lower).unwrap_or(lower)
}
pub fn is_slash_key(c: char) -> bool {
c == '/' || c == '.'
}
static MODIFIED_ENTER_REPORTED: OnceLock<bool> = OnceLock::new();
const SHIFT_ENTER: &str = "Shift+Enter";
const ALT_ENTER: &str = "Alt+Enter";
pub fn set_modified_enter_reported(reported: bool) {
let _ = MODIFIED_ENTER_REPORTED.set(reported);
}
pub fn newline_chord() -> &'static str {
chord_for(MODIFIED_ENTER_REPORTED.get().copied().unwrap_or(true))
}
const fn chord_for(modified_enter_reported: bool) -> &'static str {
if modified_enter_reported {
SHIFT_ENTER
} else {
ALT_ENTER
}
}
#[cfg(windows)]
mod win_layout {
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{
GetKeyboardLayout, GetKeyboardLayoutList, HKL, MAPVK_VK_TO_VSC, MapVirtualKeyExW,
VkKeyScanExW,
};
use windows_sys::Win32::UI::WindowsAndMessaging::{
GetForegroundWindow, GetWindowThreadProcessId,
};
const MAX_LAYOUTS: usize = 16;
const SHIFT_STATE_CTRL: i16 = 2;
const SHIFT_STATE_ALT: i16 = 4;
pub(super) fn from_active_layout(c: char) -> Option<char> {
translate(c, foreground_layout()?)
}
pub(super) fn from_installed_layouts(c: char) -> Option<char> {
installed_layouts()
.into_iter()
.find_map(|hkl| translate(c, hkl))
}
fn translate(c: char, hkl: HKL) -> Option<char> {
let code = u16::try_from(c as u32).ok()?;
let scan = unsafe {
let res = VkKeyScanExW(code, hkl);
if res == -1 {
return None;
}
if (res >> 8) & (SHIFT_STATE_CTRL | SHIFT_STATE_ALT) != 0 {
return None;
}
MapVirtualKeyExW((res & 0xFF) as u32, MAPVK_VK_TO_VSC, hkl)
};
qwerty_at(scan)
}
fn foreground_layout() -> Option<HKL> {
let hkl = unsafe {
let window = GetForegroundWindow();
if window.is_null() {
return None;
}
let thread = GetWindowThreadProcessId(window, std::ptr::null_mut());
if thread == 0 {
return None;
}
GetKeyboardLayout(thread)
};
(!hkl.is_null()).then_some(hkl)
}
fn installed_layouts() -> Vec<HKL> {
let mut list = [std::ptr::null_mut(); MAX_LAYOUTS];
let written = unsafe { GetKeyboardLayoutList(MAX_LAYOUTS as i32, list.as_mut_ptr()) };
let written = written.max(0) as usize;
list.into_iter().take(written.min(MAX_LAYOUTS)).collect()
}
fn qwerty_at(scan: u32) -> Option<char> {
const ROWS: [(u32, &str); 4] = [
(0x02, "1234567890-="),
(0x10, "qwertyuiop[]"),
(0x1E, "asdfghjkl;'"),
(0x2C, "zxcvbnm,./"),
];
match scan {
0x29 => return Some('`'),
0x2B => return Some('\\'),
_ => {}
}
ROWS.iter().find_map(|&(base, row)| {
let offset = usize::try_from(scan.checked_sub(base)?).ok()?;
row.chars().nth(offset)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn qwerty_table_covers_the_main_block() {
assert_eq!(qwerty_at(0x10), Some('q'));
assert_eq!(qwerty_at(0x19), Some('p'));
assert_eq!(qwerty_at(0x1E), Some('a'));
assert_eq!(qwerty_at(0x26), Some('l'));
assert_eq!(qwerty_at(0x2C), Some('z'));
assert_eq!(qwerty_at(0x32), Some('m'));
assert_eq!(qwerty_at(0x02), Some('1'));
assert_eq!(qwerty_at(0x0B), Some('0'));
assert_eq!(qwerty_at(0x35), Some('/'));
assert_eq!(qwerty_at(0x29), Some('`'));
assert_eq!(qwerty_at(0x2B), Some('\\'));
for scan in [0x01, 0x0E, 0x0F, 0x1C, 0x2A, 0x39, 0x56, 0x100] {
assert_eq!(qwerty_at(scan), None, "scan {scan:#x}");
}
}
#[test]
fn resolves_latin_letters_through_installed_layouts() {
let q = from_installed_layouts('q').expect("some installed layout has 'q'");
let a = from_installed_layouts('a').expect("some installed layout has 'a'");
assert!(q.is_ascii_lowercase(), "unexpected {q:?}");
assert!(a.is_ascii_lowercase(), "unexpected {a:?}");
assert_ne!(q, a, "distinct characters must resolve to distinct keys");
}
#[test]
fn agrees_with_the_static_table_when_standard_russian_is_installed() {
if from_installed_layouts('й') != Some('q') {
eprintln!(
"skipping: no standard Russian layout installed \
(nothing to cross-check the OS lookup against)"
);
return;
}
for c in "йцукенгшщзфывапролдячсмить".chars() {
assert_eq!(
from_installed_layouts(c),
super::super::jcuken_key(c),
"OS lookup and the static table disagree on {c:?}"
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::crossterm::event::KeyModifiers;
fn ctrl(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
}
#[test]
fn newline_chord_follows_what_the_terminal_can_report() {
assert_eq!(chord_for(true), "Shift+Enter");
assert_eq!(chord_for(false), "Alt+Enter");
}
#[test]
fn newline_chord_defaults_to_shift_enter_until_startup_says_otherwise() {
assert_eq!(newline_chord(), "Shift+Enter");
}
#[test]
fn latin_letters_pass_through_lowercased() {
assert_eq!(physical_char('l'), 'l');
assert_eq!(physical_char('L'), 'l');
assert_eq!(physical_char('1'), '1');
}
#[test]
fn cyrillic_maps_to_physical_qwerty_key() {
assert_eq!(physical_char('д'), 'l');
assert_eq!(physical_char('т'), 'n');
assert_eq!(physical_char('с'), 'c');
assert_eq!(physical_char('п'), 'g');
assert_eq!(physical_char('е'), 't');
assert_eq!(physical_char('з'), 'p');
assert_eq!(physical_char('Д'), 'l');
}
#[test]
fn unknown_characters_pass_through() {
for c in ['界', '😀', '\u{a0}'] {
assert_eq!(physical_char(c), lowercase(c));
}
}
#[test]
fn hotkey_char_reads_character_keys_only() {
assert_eq!(hotkey_char(&ctrl('l')), Some('l'));
assert_eq!(hotkey_char(&ctrl('L')), Some('l'));
assert_eq!(
hotkey_char(&KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL)),
None
);
assert_eq!(
hotkey_char(&KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE)),
None
);
}
#[test]
fn hotkey_char_resolves_a_non_latin_key_to_a_latin_letter() {
let resolved = hotkey_char(&ctrl('д')).expect("a character key");
assert!(resolved.is_ascii_lowercase(), "unexpected {resolved:?}");
}
#[test]
fn slash_key_accepts_latin_and_cyrillic_layout() {
assert!(is_slash_key('/'));
assert!(is_slash_key('.'));
assert!(!is_slash_key(','));
assert!(!is_slash_key('a'));
}
}