#[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),
"exclam" => Some(char_to_keysym('!')),
"quotedbl" => Some(char_to_keysym('"')),
"numbersign" => Some(char_to_keysym('#')),
"dollar" => Some(char_to_keysym('$')),
"percent" => Some(char_to_keysym('%')),
"ampersand" => Some(char_to_keysym('&')),
"apostrophe" | "quoteright" => Some(char_to_keysym('\'')),
"parenleft" => Some(char_to_keysym('(')),
"parenright" => Some(char_to_keysym(')')),
"asterisk" => Some(char_to_keysym('*')),
"plus" => Some(char_to_keysym('+')),
"comma" => Some(char_to_keysym(',')),
"minus" => Some(char_to_keysym('-')),
"period" => Some(char_to_keysym('.')),
"slash" => Some(char_to_keysym('/')),
"colon" => Some(char_to_keysym(':')),
"semicolon" => Some(char_to_keysym(';')),
"less" => Some(char_to_keysym('<')),
"equal" => Some(char_to_keysym('=')),
"greater" => Some(char_to_keysym('>')),
"question" => Some(char_to_keysym('?')),
"at" => Some(char_to_keysym('@')),
"bracketleft" => Some(char_to_keysym('[')),
"backslash" => Some(char_to_keysym('\\')),
"bracketright" => Some(char_to_keysym(']')),
"asciicircum" => Some(char_to_keysym('^')),
"underscore" => Some(char_to_keysym('_')),
"grave" | "quoteleft" => Some(char_to_keysym('`')),
"braceleft" => Some(char_to_keysym('{')),
"bar" => Some(char_to_keysym('|')),
"braceright" => Some(char_to_keysym('}')),
"asciitilde" => Some(char_to_keysym('~')),
_ => {
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'));
}
#[test]
fn punctuation_keysym_names_resolve_to_their_chars() {
for (name, ch) in [
("comma", ','),
("period", '.'),
("minus", '-'),
("plus", '+'),
("equal", '='),
("slash", '/'),
("backslash", '\\'),
("bracketleft", '['),
("bracketright", ']'),
("semicolon", ';'),
("apostrophe", '\''),
("grave", '`'),
("underscore", '_'),
("asciitilde", '~'),
] {
assert_eq!(
key_name_to_keysym(name),
Some(char_to_keysym(ch)),
"keysym name {name:?} should map to char {ch:?}"
);
}
}
#[test]
fn punctuation_keysym_names_are_case_insensitive_with_aliases() {
assert_eq!(key_name_to_keysym("COMMA"), Some(char_to_keysym(',')));
assert_eq!(
key_name_to_keysym("quoteright"),
key_name_to_keysym("apostrophe")
);
assert_eq!(key_name_to_keysym("quoteleft"), key_name_to_keysym("grave"));
}
#[test]
fn parse_chord_punctuation_name_matches_literal_char() {
let by_name = parse_chord("Ctrl+comma").expect("Ctrl+comma should parse");
let by_char = parse_chord("Ctrl+,").expect("Ctrl+, should parse");
assert_eq!(by_name.modifiers, by_char.modifiers);
assert_eq!(by_name.key, by_char.key);
assert_eq!(by_name.key, char_to_keysym(','));
}
#[test]
fn parse_chord_minus_name_avoids_separator_ambiguity() {
let c = parse_chord("Ctrl+minus").expect("Ctrl+minus should parse");
assert_eq!(c.modifiers, vec![0xffe3]);
assert_eq!(c.key, char_to_keysym('-'));
}
}