use enigo::{Direction, Enigo, Key, Keyboard, Settings};
use std::sync::Mutex;
use thiserror::Error;
static PRESS_LOCK: Mutex<()> = Mutex::new(());
#[derive(Debug, Error)]
pub enum KeyError {
#[error("empty key combo")]
Empty,
#[error("unknown key: {0}")]
UnknownKey(String),
#[error("enigo init failed: {0}")]
EnigoInit(String),
#[error("enigo input failed: {0}")]
EnigoInput(String),
}
#[derive(Debug, PartialEq, Eq)]
pub struct Combo {
pub modifiers: Vec<Key>,
pub key: Key,
}
pub fn parse_combo(combo: &str) -> Result<Combo, KeyError> {
let trimmed = combo.trim();
if trimmed.is_empty() {
return Err(KeyError::Empty);
}
if trimmed == "+" {
return Ok(Combo { modifiers: Vec::new(), key: Key::Unicode('+') });
}
let (modifier_section, main) = if let Some(prefix) = trimmed.strip_suffix('+') {
match prefix.strip_suffix('+') {
Some(body) => (body, "+"),
None if !prefix.is_empty() => {
return Err(KeyError::UnknownKey(combo.trim().to_owned()));
}
None => (prefix, "+"),
}
} else {
match trimmed.rsplit_once('+') {
Some(("", _)) => {
return Err(KeyError::UnknownKey(combo.trim().to_owned()));
}
Some((mods, k)) => (mods, k.trim()),
None => ("", trimmed),
}
};
let modifiers = if modifier_section.is_empty() {
Vec::new()
} else {
modifier_section
.split('+')
.map(|tok| {
let trimmed_tok = tok.trim();
if trimmed_tok.is_empty() {
Err(KeyError::UnknownKey(combo.trim().to_owned()))
} else {
parse_modifier(trimmed_tok)
}
})
.collect::<Result<Vec<_>, _>>()?
};
if main.is_empty() {
return Err(KeyError::Empty);
}
let key = parse_key(main)?;
Ok(Combo { modifiers, key })
}
fn parse_modifier(token: &str) -> Result<Key, KeyError> {
match token.to_ascii_lowercase().as_str() {
"ctrl" | "control" => Ok(Key::Control),
"shift" => Ok(Key::Shift),
"alt" | "option" => Ok(Key::Alt),
"meta" | "super" | "cmd" | "command" | "win" => Ok(Key::Meta),
other => Err(KeyError::UnknownKey(other.to_owned())),
}
}
#[allow(clippy::too_many_lines)]
fn parse_key(token: &str) -> Result<Key, KeyError> {
if let Some(ch) = single_char(token) {
return Ok(Key::Unicode(ch));
}
let lower = token.to_ascii_lowercase();
let key = match lower.as_str() {
"enter" | "return" => Key::Return,
"tab" => Key::Tab,
"space" | "spacebar" => Key::Space,
"escape" | "esc" => Key::Escape,
"backspace" => Key::Backspace,
"delete" | "del" => Key::Delete,
"home" => Key::Home,
"end" => Key::End,
"pageup" | "page_up" => Key::PageUp,
"pagedown" | "page_down" => Key::PageDown,
"up" | "uparrow" | "arrowup" => Key::UpArrow,
"down" | "downarrow" | "arrowdown" => Key::DownArrow,
"left" | "leftarrow" | "arrowleft" => Key::LeftArrow,
"right" | "rightarrow" | "arrowright" => Key::RightArrow,
"ctrl" | "control" => Key::Control,
"shift" => Key::Shift,
"alt" | "option" => Key::Alt,
"meta" | "super" | "cmd" | "command" | "win" => Key::Meta,
"f1" => Key::F1,
"f2" => Key::F2,
"f3" => Key::F3,
"f4" => Key::F4,
"f5" => Key::F5,
"f6" => Key::F6,
"f7" => Key::F7,
"f8" => Key::F8,
"f9" => Key::F9,
"f10" => Key::F10,
"f11" => Key::F11,
"f12" => Key::F12,
_ => return Err(KeyError::UnknownKey(token.to_owned())),
};
Ok(key)
}
fn single_char(token: &str) -> Option<char> {
let mut chars = token.chars();
let first = chars.next()?;
if chars.next().is_none() { Some(first) } else { None }
}
#[cfg(target_os = "linux")]
fn linux_digit_keycode(ch: char) -> Option<u16> {
match ch {
'1'..='9' => Some(10 + (ch as u16 - '1' as u16)),
'0' => Some(19),
_ => None,
}
}
#[cfg(target_os = "linux")]
fn physical_digit_keycode(key: Key, has_modifiers: bool) -> Option<u16> {
match key {
Key::Unicode(ch) if has_modifiers => linux_digit_keycode(ch),
_ => None,
}
}
#[cfg(target_os = "linux")]
fn normalize_main_key(key: Key, has_modifiers: bool) -> Key {
match key {
Key::Unicode(ch) if has_modifiers && ch.is_ascii_uppercase() => Key::Unicode(ch.to_ascii_lowercase()),
_ => key,
}
}
#[cfg(target_os = "linux")]
fn tap_main_key(enigo: &mut Enigo, key: Key, has_modifiers: bool) -> Result<(), KeyError> {
if let Some(keycode) = physical_digit_keycode(key, has_modifiers) {
return enigo.raw(keycode, Direction::Click).map_err(|e| KeyError::EnigoInput(e.to_string()));
}
enigo.key(normalize_main_key(key, has_modifiers), Direction::Click).map_err(|e| KeyError::EnigoInput(e.to_string()))
}
#[cfg(not(target_os = "linux"))]
fn tap_main_key(enigo: &mut Enigo, key: Key, _has_modifiers: bool) -> Result<(), KeyError> {
enigo.key(key, Direction::Click).map_err(|e| KeyError::EnigoInput(e.to_string()))
}
pub fn simulate_press(combo: &str) -> Result<(), KeyError> {
let parsed = parse_combo(combo)?;
let _guard = PRESS_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let mut enigo = Enigo::new(&Settings::default()).map_err(|e| {
#[cfg(target_os = "macos")]
let msg = format!("{e} (on macOS, grant Accessibility permission to the launching terminal)");
#[cfg(not(target_os = "macos"))]
let msg = e.to_string();
KeyError::EnigoInit(msg)
})?;
let mut pressed: Vec<Key> = Vec::with_capacity(parsed.modifiers.len());
let press_outcome = (|| -> Result<(), KeyError> {
for m in &parsed.modifiers {
enigo.key(*m, Direction::Press).map_err(|e| KeyError::EnigoInput(e.to_string()))?;
pressed.push(*m);
}
tap_main_key(&mut enigo, parsed.key, !parsed.modifiers.is_empty())
})();
let mut release_error: Option<KeyError> = None;
for m in pressed.iter().rev() {
if let Err(e) = enigo.key(*m, Direction::Release)
&& release_error.is_none()
{
release_error = Some(KeyError::EnigoInput(format!("modifier release failed (possible stuck key): {e}")));
}
}
press_outcome?;
match release_error {
Some(e) => Err(e),
None => Ok(()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key_eq(a: Key, b: Key) -> bool {
format!("{a:?}") == format!("{b:?}")
}
#[test]
fn test_parse_single_char_returns_unicode() {
let combo = parse_combo("a").expect("parse_combo succeeds");
assert!(combo.modifiers.is_empty());
assert!(key_eq(combo.key, Key::Unicode('a')));
}
#[test]
fn test_parse_ctrl_plus_one() {
let combo = parse_combo("Control+1").expect("parse_combo succeeds");
assert_eq!(combo.modifiers.len(), 1);
assert!(key_eq(combo.modifiers[0], Key::Control));
assert!(key_eq(combo.key, Key::Unicode('1')));
}
#[test]
fn test_parse_ctrl_shift_p() {
let combo = parse_combo("Ctrl+Shift+P").expect("parse_combo succeeds");
assert_eq!(combo.modifiers.len(), 2);
assert!(key_eq(combo.modifiers[0], Key::Control));
assert!(key_eq(combo.modifiers[1], Key::Shift));
assert!(key_eq(combo.key, Key::Unicode('P')));
}
#[test]
fn test_parse_named_key_enter() {
let combo = parse_combo("Enter").expect("parse_combo succeeds");
assert!(key_eq(combo.key, Key::Return));
}
#[test]
fn test_parse_function_key_f5() {
let combo = parse_combo("F5").expect("parse_combo succeeds");
assert!(key_eq(combo.key, Key::F5));
}
#[test]
fn test_parse_arrow_key() {
let combo = parse_combo("ArrowUp").expect("parse_combo succeeds");
assert!(key_eq(combo.key, Key::UpArrow));
}
#[test]
fn test_parse_meta_aliases_resolve_to_meta() {
for alias in ["Meta+a", "Cmd+a", "Super+a", "Win+a", "Command+a"] {
let combo = parse_combo(alias).expect("parse_combo succeeds");
assert!(key_eq(combo.modifiers[0], Key::Meta), "alias: {alias}");
}
}
#[test]
fn test_parse_dash_is_treated_as_minus_key() {
let combo = parse_combo("Shift+-").expect("parse_combo succeeds");
assert_eq!(combo.modifiers.len(), 1);
assert!(key_eq(combo.modifiers[0], Key::Shift));
assert!(key_eq(combo.key, Key::Unicode('-')));
}
#[test]
fn test_parse_plus_alone_is_plus_key() {
let combo = parse_combo("+").expect("parse_combo succeeds");
assert!(combo.modifiers.is_empty());
assert!(key_eq(combo.key, Key::Unicode('+')));
}
#[test]
fn test_parse_trailing_plus_is_plus_key_with_modifiers() {
let combo = parse_combo("Control++").expect("parse_combo succeeds");
assert_eq!(combo.modifiers.len(), 1);
assert!(key_eq(combo.modifiers[0], Key::Control));
assert!(key_eq(combo.key, Key::Unicode('+')));
}
#[test]
fn test_parse_case_insensitive_modifiers() {
let combo = parse_combo("CONTROL+a").expect("parse_combo succeeds");
assert!(key_eq(combo.modifiers[0], Key::Control));
}
#[test]
fn test_parse_empty_returns_error() {
assert!(matches!(parse_combo(""), Err(KeyError::Empty)));
assert!(matches!(parse_combo(" "), Err(KeyError::Empty)));
}
#[test]
fn test_parse_triple_plus_returns_error() {
assert!(matches!(parse_combo("+++"), Err(KeyError::UnknownKey(_))));
}
#[test]
fn test_parse_empty_modifier_segment_returns_error() {
assert!(matches!(parse_combo("Control++P"), Err(KeyError::UnknownKey(_))));
}
#[test]
fn test_parse_leading_plus_returns_error() {
assert!(matches!(parse_combo("+A"), Err(KeyError::UnknownKey(_))));
}
#[test]
fn test_parse_unknown_modifier_returns_error() {
assert!(matches!(parse_combo("Hyper+a"), Err(KeyError::UnknownKey(_))));
}
#[test]
fn test_parse_unknown_key_returns_error() {
assert!(matches!(parse_combo("Ctrl+NotAKey"), Err(KeyError::UnknownKey(_))));
}
#[cfg(target_os = "linux")]
#[test]
fn test_linux_digit_keycode_maps_digits_to_physical_x11_keycodes() {
assert_eq!(linux_digit_keycode('1'), Some(10));
assert_eq!(linux_digit_keycode('2'), Some(11));
assert_eq!(linux_digit_keycode('5'), Some(14));
assert_eq!(linux_digit_keycode('9'), Some(18));
assert_eq!(linux_digit_keycode('0'), Some(19));
}
#[cfg(target_os = "linux")]
#[test]
fn test_linux_digit_keycode_returns_none_for_non_digits() {
for ch in ['a', 'A', 'p', '+', '-', ' ', 'é'] {
assert_eq!(linux_digit_keycode(ch), None, "char: {ch}");
}
}
#[cfg(target_os = "linux")]
#[test]
fn test_physical_digit_keycode_used_for_modified_digit_combos() {
assert_eq!(physical_digit_keycode(Key::Unicode('1'), true), Some(10));
assert_eq!(physical_digit_keycode(Key::Unicode('0'), true), Some(19));
}
#[cfg(target_os = "linux")]
#[test]
fn test_physical_digit_keycode_skips_bare_digit_for_character_entry() {
assert_eq!(physical_digit_keycode(Key::Unicode('1'), false), None);
assert_eq!(physical_digit_keycode(Key::Unicode('9'), false), None);
}
#[cfg(target_os = "linux")]
#[test]
fn test_physical_digit_keycode_skips_non_digit_keys() {
assert_eq!(physical_digit_keycode(Key::Unicode('a'), true), None);
assert_eq!(physical_digit_keycode(Key::Return, true), None);
}
#[cfg(target_os = "linux")]
#[test]
fn test_normalize_main_key_lowercases_modified_uppercase_letter() {
assert!(key_eq(normalize_main_key(Key::Unicode('P'), true), Key::Unicode('p')));
}
#[cfg(target_os = "linux")]
#[test]
fn test_normalize_main_key_keeps_bare_uppercase_letter() {
assert!(key_eq(normalize_main_key(Key::Unicode('P'), false), Key::Unicode('P')));
}
#[cfg(target_os = "linux")]
#[test]
fn test_normalize_main_key_keeps_lowercase_letter() {
assert!(key_eq(normalize_main_key(Key::Unicode('p'), true), Key::Unicode('p')));
assert!(key_eq(normalize_main_key(Key::Unicode('p'), false), Key::Unicode('p')));
}
#[cfg(target_os = "linux")]
#[test]
fn test_normalize_main_key_keeps_non_letter_keys() {
assert!(key_eq(normalize_main_key(Key::Unicode('1'), true), Key::Unicode('1')));
assert!(key_eq(normalize_main_key(Key::Unicode('+'), true), Key::Unicode('+')));
assert!(key_eq(normalize_main_key(Key::Return, true), Key::Return));
}
#[cfg(target_os = "linux")]
#[test]
fn test_normalize_main_key_keeps_non_ascii_uppercase() {
assert!(key_eq(normalize_main_key(Key::Unicode('É'), true), Key::Unicode('É')));
}
}