#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Chord {
pub modifiers: Vec<u32>,
pub key: u32,
}
pub fn parse_chord(input: &str) -> Option<Chord> {
let trimmed = input.trim();
if trimmed.chars().count() == 1 {
let key = key_name_to_keysym(trimmed)?;
return Some(Chord {
modifiers: Vec::new(),
key,
});
}
let tokens: Vec<&str> = input
.split(['+', '-'])
.map(str::trim)
.filter(|t| !t.is_empty())
.collect();
let (target_token, modifier_tokens) = tokens.split_last()?;
let key = modifier_name_to_keysym(target_token).or_else(|| key_name_to_keysym(target_token))?;
let modifiers = modifier_tokens
.iter()
.map(|m| modifier_name_to_keysym(m))
.collect::<Option<Vec<u32>>>()?;
Some(Chord { modifiers, key })
}
pub fn modifier_name_to_keysym(name: &str) -> Option<u32> {
match name.to_lowercase().as_str() {
"ctrl" | "control" => Some(0xffe3),
"shift" => Some(0xffe1),
"alt" => Some(0xffe9),
"super" | "meta" | "win" | "windows" | "cmd" | "command" => Some(0xffeb),
_ => None,
}
}
pub fn key_name_to_keysym(key: &str) -> Option<u32> {
match key.to_lowercase().as_str() {
"return" | "enter" => Some(0xff0d),
"tab" => Some(0xff09),
"escape" | "esc" => Some(0xff1b),
"backspace" => Some(0xff08),
"delete" => Some(0xffff),
"space" => Some(0x0020),
"up" => Some(0xff52),
"down" => Some(0xff54),
"left" => Some(0xff51),
"right" => Some(0xff53),
"home" => Some(0xff50),
"end" => Some(0xff57),
"page_up" => Some(0xff55),
"page_down" => Some(0xff56),
"f1" => Some(0xffbe),
"f2" => Some(0xffbf),
"f3" => Some(0xffc0),
"f4" => Some(0xffc1),
"f5" => Some(0xffc2),
"f6" => Some(0xffc3),
"f7" => Some(0xffc4),
"f8" => Some(0xffc5),
"f9" => Some(0xffc6),
"f10" => Some(0xffc7),
"f11" => Some(0xffc8),
"f12" => Some(0xffc9),
_ => {
let mut chars = key.chars();
let first = chars.next()?;
if chars.next().is_some() {
return None;
}
Some(char_to_keysym(first))
}
}
}
pub fn char_to_keysym(ch: char) -> u32 {
let cp = ch as u32;
if (0x20..=0xff).contains(&cp) {
cp
} else if cp > 0xff {
0x01000000 + cp
} else {
cp
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_named_keys() {
assert_eq!(key_name_to_keysym("Return"), Some(0xff0d));
assert_eq!(key_name_to_keysym("enter"), Some(0xff0d));
assert_eq!(key_name_to_keysym("Tab"), Some(0xff09));
assert_eq!(key_name_to_keysym("Escape"), Some(0xff1b));
assert_eq!(key_name_to_keysym("esc"), Some(0xff1b));
assert_eq!(key_name_to_keysym("BackSpace"), Some(0xff08));
assert_eq!(key_name_to_keysym("Delete"), Some(0xffff));
assert_eq!(key_name_to_keysym("Space"), Some(0x0020));
assert_eq!(key_name_to_keysym("Up"), Some(0xff52));
assert_eq!(key_name_to_keysym("Down"), Some(0xff54));
assert_eq!(key_name_to_keysym("Left"), Some(0xff51));
assert_eq!(key_name_to_keysym("Right"), Some(0xff53));
assert_eq!(key_name_to_keysym("Home"), Some(0xff50));
assert_eq!(key_name_to_keysym("End"), Some(0xff57));
assert_eq!(key_name_to_keysym("Page_Up"), Some(0xff55));
assert_eq!(key_name_to_keysym("Page_Down"), Some(0xff56));
assert_eq!(key_name_to_keysym("F1"), Some(0xffbe));
assert_eq!(key_name_to_keysym("F6"), Some(0xffc3));
assert_eq!(key_name_to_keysym("F12"), Some(0xffc9));
}
#[test]
fn test_key_name_case_insensitive() {
assert_eq!(key_name_to_keysym("RETURN"), Some(0xff0d));
assert_eq!(key_name_to_keysym("rEtUrN"), Some(0xff0d));
assert_eq!(key_name_to_keysym("TAB"), Some(0xff09));
assert_eq!(key_name_to_keysym("ESCAPE"), Some(0xff1b));
}
#[test]
fn test_single_char_keys() {
assert_eq!(key_name_to_keysym("a"), Some(char_to_keysym('a')));
assert_eq!(key_name_to_keysym("z"), Some(char_to_keysym('z')));
assert_eq!(key_name_to_keysym("0"), Some(char_to_keysym('0')));
assert_eq!(key_name_to_keysym("!"), Some(char_to_keysym('!')));
}
#[test]
fn test_unknown_key_returns_none() {
assert_eq!(key_name_to_keysym("ctrl"), None);
assert_eq!(key_name_to_keysym("alt"), None);
assert_eq!(key_name_to_keysym("super"), None);
assert_eq!(key_name_to_keysym("shift"), None);
assert_eq!(key_name_to_keysym("unknown_key"), None);
}
#[test]
fn test_char_to_keysym_printable_ascii() {
assert_eq!(char_to_keysym('a'), 0x61);
assert_eq!(char_to_keysym('A'), 0x41);
assert_eq!(char_to_keysym('0'), 0x30);
assert_eq!(char_to_keysym(' '), 0x20);
assert_eq!(char_to_keysym('~'), 0x7e);
assert_eq!(char_to_keysym('ñ'), 0xf1);
assert_eq!(char_to_keysym('ÿ'), 0xff);
}
#[test]
fn test_char_to_keysym_unicode() {
assert_eq!(char_to_keysym('€'), 0x01000000 + 0x20AC);
assert_eq!(char_to_keysym('中'), 0x01000000 + 0x4E2D);
}
#[test]
fn test_char_to_keysym_control() {
assert_eq!(char_to_keysym('\x00'), 0x00);
assert_eq!(char_to_keysym('\x01'), 0x01);
assert_eq!(char_to_keysym('\x1f'), 0x1f);
}
#[test]
fn modifier_name_to_keysym_aliases() {
assert_eq!(modifier_name_to_keysym("Ctrl"), Some(0xffe3));
assert_eq!(modifier_name_to_keysym("control"), Some(0xffe3));
assert_eq!(modifier_name_to_keysym("CONTROL"), Some(0xffe3));
assert_eq!(modifier_name_to_keysym("Shift"), Some(0xffe1));
assert_eq!(modifier_name_to_keysym("shift"), Some(0xffe1));
assert_eq!(modifier_name_to_keysym("alt"), Some(0xffe9));
assert_eq!(modifier_name_to_keysym("Super"), Some(0xffeb));
assert_eq!(modifier_name_to_keysym("Meta"), Some(0xffeb));
assert_eq!(modifier_name_to_keysym("win"), Some(0xffeb));
assert_eq!(modifier_name_to_keysym("Windows"), Some(0xffeb));
assert_eq!(modifier_name_to_keysym("cmd"), Some(0xffeb));
assert_eq!(modifier_name_to_keysym("Command"), Some(0xffeb));
}
#[test]
fn modifier_name_to_keysym_rejects_non_modifiers() {
assert_eq!(modifier_name_to_keysym("Return"), None);
assert_eq!(modifier_name_to_keysym("a"), None);
assert_eq!(modifier_name_to_keysym(""), None);
}
#[test]
fn parse_chord_single_key_has_empty_modifiers() {
let c = parse_chord("Return").unwrap();
assert!(c.modifiers.is_empty());
assert_eq!(c.key, 0xff0d);
}
#[test]
fn parse_chord_single_char() {
let c = parse_chord("a").unwrap();
assert!(c.modifiers.is_empty());
assert_eq!(c.key, char_to_keysym('a'));
}
#[test]
fn parse_chord_basic_ctrl_a() {
let c = parse_chord("Ctrl+A").unwrap();
assert_eq!(c.modifiers, vec![0xffe3]);
assert_eq!(c.key, char_to_keysym('A'));
}
#[test]
fn parse_chord_multiple_modifiers_preserve_order() {
let c = parse_chord("Ctrl+Shift+Alt+A").unwrap();
assert_eq!(c.modifiers, vec![0xffe3, 0xffe1, 0xffe9]);
assert_eq!(c.key, char_to_keysym('A'));
}
#[test]
fn parse_chord_dash_separator_works() {
let c = parse_chord("Ctrl-Shift-A").unwrap();
assert_eq!(c.modifiers, vec![0xffe3, 0xffe1]);
assert_eq!(c.key, char_to_keysym('A'));
}
#[test]
fn parse_chord_mixed_separators() {
let c = parse_chord("Ctrl+Shift-A").unwrap();
assert_eq!(c.modifiers, vec![0xffe3, 0xffe1]);
assert_eq!(c.key, char_to_keysym('A'));
}
#[test]
fn parse_chord_is_case_insensitive() {
let c = parse_chord("CTRL+shift+A").unwrap();
assert_eq!(c.modifiers, vec![0xffe3, 0xffe1]);
}
#[test]
fn parse_chord_named_key_target() {
let c = parse_chord("Alt+Return").unwrap();
assert_eq!(c.modifiers, vec![0xffe9]);
assert_eq!(c.key, 0xff0d);
}
#[test]
fn parse_chord_bare_modifier_is_single_key() {
let c = parse_chord("Ctrl").unwrap();
assert!(c.modifiers.is_empty());
assert_eq!(c.key, 0xffe3);
}
#[test]
fn parse_chord_empty_returns_none() {
assert_eq!(parse_chord(""), None);
assert_eq!(parse_chord(" "), None);
assert_eq!(parse_chord("-+-"), None);
}
#[test]
fn parse_chord_accepts_multibyte_single_char() {
for ch in ['é', 'ß', '€', '日'] {
let s = ch.to_string();
let c = parse_chord(&s)
.unwrap_or_else(|| panic!("parse_chord rejected single char {ch:?}"));
assert!(c.modifiers.is_empty());
assert_eq!(c.key, char_to_keysym(ch));
}
}
#[test]
fn key_name_to_keysym_rejects_multi_char_unknown() {
assert_eq!(key_name_to_keysym("abc"), None);
}
#[test]
fn key_name_to_keysym_single_char_preserves_case() {
assert_eq!(key_name_to_keysym("É"), Some(char_to_keysym('É')));
assert_eq!(key_name_to_keysym("é"), Some(char_to_keysym('é')));
assert_ne!(
key_name_to_keysym("É").unwrap(),
key_name_to_keysym("é").unwrap()
);
assert_eq!(key_name_to_keysym("A"), Some(char_to_keysym('A')));
assert_eq!(key_name_to_keysym("a"), Some(char_to_keysym('a')));
assert_ne!(
key_name_to_keysym("A").unwrap(),
key_name_to_keysym("a").unwrap()
);
}
#[test]
fn parse_chord_separator_char_is_a_literal_key() {
let plus = parse_chord("+").unwrap();
assert!(plus.modifiers.is_empty());
assert_eq!(plus.key, char_to_keysym('+'));
let minus = parse_chord("-").unwrap();
assert_eq!(minus.key, char_to_keysym('-'));
}
#[test]
fn parse_chord_unknown_modifier_returns_none() {
assert_eq!(parse_chord("Hyper+A"), None);
}
#[test]
fn parse_chord_unknown_target_returns_none() {
assert_eq!(parse_chord("Ctrl+NoSuchKey"), None);
}
#[test]
fn parse_chord_non_modifier_in_middle_rejected() {
assert_eq!(parse_chord("Ctrl+A+B"), None);
}
#[test]
fn parse_chord_whitespace_is_trimmed() {
let c = parse_chord(" Ctrl + A ").unwrap();
assert_eq!(c.modifiers, vec![0xffe3]);
assert_eq!(c.key, char_to_keysym('A'));
}
}