use evdev::Key;
#[derive(Debug, Clone)]
pub struct HotkeyBinding {
pub modifiers: Vec<Key>,
pub trigger: Key,
}
pub fn parse_hotkey(s: &str) -> anyhow::Result<HotkeyBinding> {
let parts: Vec<&str> = s.split('+').map(|p| p.trim()).collect();
if parts.is_empty() {
anyhow::bail!("empty hotkey string");
}
if parts.len() < 2 {
anyhow::bail!(
"hotkey must have at least one modifier and a key (e.g. \"Super+D\"), got: {s}"
);
}
let mut modifiers = Vec::new();
for part in &parts[..parts.len() - 1] {
let key = parse_modifier(part).ok_or_else(|| {
anyhow::anyhow!(
"unknown modifier '{part}' in hotkey '{s}'. Valid: Super, Alt, Ctrl, Shift"
)
})?;
modifiers.push(key);
}
let trigger_str = parts.last().unwrap();
let trigger = parse_key(trigger_str)
.ok_or_else(|| anyhow::anyhow!("unknown key '{trigger_str}' in hotkey '{s}'"))?;
Ok(HotkeyBinding { modifiers, trigger })
}
fn parse_modifier(s: &str) -> Option<Key> {
match s.to_lowercase().as_str() {
"super" | "meta" | "win" | "hyper" => Some(Key::KEY_LEFTMETA),
"alt" => Some(Key::KEY_LEFTALT),
"ctrl" | "control" => Some(Key::KEY_LEFTCTRL),
"shift" => Some(Key::KEY_LEFTSHIFT),
_ => None,
}
}
fn parse_key(s: &str) -> Option<Key> {
if s.len() == 1 {
let ch = s.to_uppercase().chars().next()?;
return match ch {
'A' => Some(Key::KEY_A),
'B' => Some(Key::KEY_B),
'C' => Some(Key::KEY_C),
'D' => Some(Key::KEY_D),
'E' => Some(Key::KEY_E),
'F' => Some(Key::KEY_F),
'G' => Some(Key::KEY_G),
'H' => Some(Key::KEY_H),
'I' => Some(Key::KEY_I),
'J' => Some(Key::KEY_J),
'K' => Some(Key::KEY_K),
'L' => Some(Key::KEY_L),
'M' => Some(Key::KEY_M),
'N' => Some(Key::KEY_N),
'O' => Some(Key::KEY_O),
'P' => Some(Key::KEY_P),
'Q' => Some(Key::KEY_Q),
'R' => Some(Key::KEY_R),
'S' => Some(Key::KEY_S),
'T' => Some(Key::KEY_T),
'U' => Some(Key::KEY_U),
'V' => Some(Key::KEY_V),
'W' => Some(Key::KEY_W),
'X' => Some(Key::KEY_X),
'Y' => Some(Key::KEY_Y),
'Z' => Some(Key::KEY_Z),
_ => None,
};
}
match s.to_lowercase().as_str() {
"space" => Some(Key::KEY_SPACE),
"enter" | "return" => Some(Key::KEY_ENTER),
"escape" | "esc" => Some(Key::KEY_ESC),
"tab" => Some(Key::KEY_TAB),
"backspace" => Some(Key::KEY_BACKSPACE),
"delete" | "del" => Some(Key::KEY_DELETE),
"insert" | "ins" => Some(Key::KEY_INSERT),
"home" => Some(Key::KEY_HOME),
"end" => Some(Key::KEY_END),
"pageup" | "pgup" => Some(Key::KEY_PAGEUP),
"pagedown" | "pgdn" => Some(Key::KEY_PAGEDOWN),
"up" => Some(Key::KEY_UP),
"down" => Some(Key::KEY_DOWN),
"left" => Some(Key::KEY_LEFT),
"right" => Some(Key::KEY_RIGHT),
"f1" => Some(Key::KEY_F1),
"f2" => Some(Key::KEY_F2),
"f3" => Some(Key::KEY_F3),
"f4" => Some(Key::KEY_F4),
"f5" => Some(Key::KEY_F5),
"f6" => Some(Key::KEY_F6),
"f7" => Some(Key::KEY_F7),
"f8" => Some(Key::KEY_F8),
"f9" => Some(Key::KEY_F9),
"f10" => Some(Key::KEY_F10),
"f11" => Some(Key::KEY_F11),
"f12" => Some(Key::KEY_F12),
"0" => Some(Key::KEY_0),
"1" => Some(Key::KEY_1),
"2" => Some(Key::KEY_2),
"3" => Some(Key::KEY_3),
"4" => Some(Key::KEY_4),
"5" => Some(Key::KEY_5),
"6" => Some(Key::KEY_6),
"7" => Some(Key::KEY_7),
"8" => Some(Key::KEY_8),
"9" => Some(Key::KEY_9),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_super_d() {
let binding = parse_hotkey("Super+D").unwrap();
assert_eq!(binding.modifiers, vec![Key::KEY_LEFTMETA]);
assert_eq!(binding.trigger, Key::KEY_D);
}
#[test]
fn parse_super_shift_c() {
let binding = parse_hotkey("Super+Shift+C").unwrap();
assert_eq!(binding.modifiers.len(), 2);
assert_eq!(binding.trigger, Key::KEY_C);
}
#[test]
fn parse_ctrl_alt_f5() {
let binding = parse_hotkey("Ctrl+Alt+F5").unwrap();
assert_eq!(binding.modifiers.len(), 2);
assert_eq!(binding.trigger, Key::KEY_F5);
}
#[test]
fn parse_case_insensitive() {
let binding = parse_hotkey("super+shift+d").unwrap();
assert_eq!(binding.trigger, Key::KEY_D);
}
#[test]
fn parse_no_modifier_fails() {
assert!(parse_hotkey("D").is_err());
}
#[test]
fn parse_unknown_key_fails() {
assert!(parse_hotkey("Super+Unknown").is_err());
}
}