use std::fmt;
use std::str::FromStr;
use crate::error::Error;
bitflags::bitflags! {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct Modifiers: u8 {
const SHIFT = 1 << 0;
const CONTROL = 1 << 1;
const ALT = 1 << 2;
const META = 1 << 3;
const FUNCTION = 1 << 4;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum KeypadKey {
Digit(u8),
Enter,
Equal,
Multiply,
Add,
Subtract,
Decimal,
Divide,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Key {
Char(char),
Return,
Tab,
Space,
Escape,
Backspace,
Delete,
Insert,
Up,
Down,
Left,
Right,
Home,
End,
PageUp,
PageDown,
CapsLock,
Menu,
F(u8),
Keypad(KeypadKey),
Modifier(Modifiers),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct KeyChord {
pub modifiers: Modifiers,
pub key: Key,
}
impl KeyChord {
#[must_use]
pub const fn new(key: Key) -> Self {
Self { modifiers: Modifiers::empty(), key }
}
#[must_use]
pub const fn with(mut self, modifiers: Modifiers) -> Self {
self.modifiers = self.modifiers.union(modifiers);
self
}
}
impl FromStr for KeyChord {
type Err = Error;
fn from_str(spec: &str) -> Result<Self, Self::Err> {
parse_chord(spec)
}
}
impl fmt::Display for KeyChord {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for (flag, name) in [
(Modifiers::CONTROL, "ctrl"),
(Modifiers::ALT, "alt"),
(Modifiers::SHIFT, "shift"),
(Modifiers::META, "super"),
(Modifiers::FUNCTION, "fn"),
] {
if self.modifiers.contains(flag) {
write!(f, "{name}+")?;
}
}
match self.key {
Key::Char(c) => write!(f, "{c}"),
Key::F(n) => write!(f, "F{n}"),
Key::Keypad(KeypadKey::Digit(d)) => write!(f, "KP_{d}"),
Key::Keypad(k) => write!(f, "KP_{k:?}"),
Key::Modifier(m) => write!(f, "{m:?}"),
other => write!(f, "{other:?}"),
}
}
}
fn parse_chord(spec: &str) -> Result<KeyChord, Error> {
let spec = spec.trim();
if spec.is_empty() {
return Err(Error::invalid("key specification is empty"));
}
let (body, trailing_plus) = match spec.strip_suffix("++") {
Some(rest) => (rest, true),
None if spec == "+" => ("", true),
None => (spec, false),
};
let mut tokens: Vec<&str> = body.split('+').map(str::trim).filter(|t| !t.is_empty()).collect();
let key_token = if trailing_plus {
"+"
} else {
tokens.pop().ok_or_else(|| Error::invalid(format!("unsupported key '{spec}'")))?
};
let mut modifiers = Modifiers::empty();
for token in tokens {
modifiers |= parse_modifier(token)
.ok_or_else(|| Error::invalid(format!("unsupported modifier '{token}' in '{spec}'")))?;
}
let key = parse_key(key_token).ok_or_else(|| Error::invalid(format!("unsupported key '{spec}'")))?;
if let Key::Char(c) = key
&& key_token.chars().next().is_some_and(char::is_uppercase)
&& key_token.chars().count() == 1
{
return Ok(KeyChord { modifiers: modifiers | Modifiers::SHIFT, key: Key::Char(c) });
}
Ok(KeyChord { modifiers, key })
}
fn parse_modifier(token: &str) -> Option<Modifiers> {
Some(match token.to_ascii_lowercase().as_str() {
"shift" | "shift_l" | "shift_r" => Modifiers::SHIFT,
"ctrl" | "control" | "control_l" | "control_r" | "ctrl_l" | "ctrl_r" => Modifiers::CONTROL,
"alt" | "option" | "opt" | "alt_l" | "alt_r" | "mod1" => Modifiers::ALT,
"super" | "cmd" | "command" | "meta" | "win" | "windows" | "super_l" | "super_r" | "meta_l" | "meta_r"
| "mod4" => Modifiers::META,
"fn" | "function" => Modifiers::FUNCTION,
_ => return None,
})
}
fn parse_key(token: &str) -> Option<Key> {
let mut chars = token.chars();
if let (Some(c), None) = (chars.next(), chars.next()) {
return Some(match c {
' ' => Key::Space,
'\n' | '\r' => Key::Return,
'\t' => Key::Tab,
c => Key::Char(c.to_lowercase().next().unwrap_or(c)),
});
}
if let Some(m) = parse_modifier(token) {
return Some(Key::Modifier(m));
}
let lower = token.to_ascii_lowercase().replace([' ', '-'], "_");
if let Some(n) = lower.strip_prefix('f').and_then(|n| n.parse::<u8>().ok())
&& (1..=24).contains(&n)
{
return Some(Key::F(n));
}
if let Some(rest) = lower.strip_prefix("kp_") {
return parse_keypad(rest);
}
Some(match lower.as_str() {
"return" | "enter" => Key::Return,
"tab" | "iso_left_tab" => Key::Tab,
"space" | "spacebar" => Key::Space,
"escape" | "esc" => Key::Escape,
"backspace" => Key::Backspace,
"delete" | "del" | "forwarddelete" | "forward_delete" => Key::Delete,
"insert" | "ins" | "help" => Key::Insert,
"up" | "arrowup" | "arrow_up" | "uparrow" => Key::Up,
"down" | "arrowdown" | "arrow_down" | "downarrow" => Key::Down,
"left" | "arrowleft" | "arrow_left" | "leftarrow" => Key::Left,
"right" | "arrowright" | "arrow_right" | "rightarrow" => Key::Right,
"home" => Key::Home,
"end" => Key::End,
"page_up" | "pageup" | "prior" => Key::PageUp,
"page_down" | "pagedown" | "next" => Key::PageDown,
"caps_lock" | "capslock" => Key::CapsLock,
"menu" | "apps" | "contextmenu" => Key::Menu,
_ => Key::Char(keysym_char(&lower)?),
})
}
fn parse_keypad(rest: &str) -> Option<Key> {
if let Ok(d) = rest.parse::<u8>()
&& d <= 9
{
return Some(Key::Keypad(KeypadKey::Digit(d)));
}
Some(match rest {
"enter" => Key::Keypad(KeypadKey::Enter),
"equal" => Key::Keypad(KeypadKey::Equal),
"multiply" => Key::Keypad(KeypadKey::Multiply),
"add" => Key::Keypad(KeypadKey::Add),
"subtract" => Key::Keypad(KeypadKey::Subtract),
"decimal" | "delete" => Key::Keypad(KeypadKey::Decimal),
"divide" => Key::Keypad(KeypadKey::Divide),
"home" => Key::Home,
"end" => Key::End,
"left" => Key::Left,
"right" => Key::Right,
"up" => Key::Up,
"down" => Key::Down,
"prior" | "page_up" => Key::PageUp,
"next" | "page_down" => Key::PageDown,
"insert" => Key::Insert,
_ => return None,
})
}
fn keysym_char(name: &str) -> Option<char> {
Some(match name {
"minus" => '-',
"equal" => '=',
"plus" => '+',
"bracketleft" => '[',
"bracketright" => ']',
"braceleft" => '{',
"braceright" => '}',
"backslash" => '\\',
"bar" => '|',
"semicolon" => ';',
"colon" => ':',
"apostrophe" | "quoteright" => '\'',
"quotedbl" => '"',
"grave" | "quoteleft" => '`',
"asciitilde" => '~',
"comma" => ',',
"period" => '.',
"slash" => '/',
"question" => '?',
"less" => '<',
"greater" => '>',
"exclam" => '!',
"at" => '@',
"numbersign" => '#',
"dollar" => '$',
"percent" => '%',
"asciicircum" => '^',
"ampersand" => '&',
"asterisk" => '*',
"parenleft" => '(',
"parenright" => ')',
"underscore" => '_',
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn chord(spec: &str) -> KeyChord {
spec.parse().unwrap_or_else(|e| panic!("{spec}: {e}"))
}
#[test]
fn parses_xdotool_names() {
assert_eq!(chord("Return").key, Key::Return);
assert_eq!(chord("BackSpace").key, Key::Backspace);
assert_eq!(chord("KP_0").key, Key::Keypad(KeypadKey::Digit(0)));
assert_eq!(chord("Page_Up").key, Key::PageUp);
assert_eq!(chord("Next").key, Key::PageDown);
assert_eq!(chord("F12").key, Key::F(12));
assert_eq!(chord("bracketleft").key, Key::Char('['));
assert_eq!(chord("space").key, Key::Space);
}
#[test]
fn parses_modifiers() {
let c = chord("super+c");
assert_eq!(c.modifiers, Modifiers::META);
assert_eq!(c.key, Key::Char('c'));
let c = chord("ctrl + shift + Tab");
assert_eq!(c.modifiers, Modifiers::CONTROL | Modifiers::SHIFT);
assert_eq!(c.key, Key::Tab);
assert_eq!(chord("cmd+A").modifiers, Modifiers::META | Modifiers::SHIFT);
assert_eq!(chord("shift").key, Key::Modifier(Modifiers::SHIFT));
}
#[test]
fn parses_plus_key() {
let c = chord("ctrl++");
assert_eq!((c.modifiers, c.key), (Modifiers::CONTROL, Key::Char('+')));
assert_eq!(chord("+").key, Key::Char('+'));
assert_eq!(chord("ctrl+plus").key, Key::Char('+'));
}
#[test]
fn rejects_garbage() {
assert!("".parse::<KeyChord>().is_err());
assert!("hyper+x".parse::<KeyChord>().is_err());
assert!("NotAKey".parse::<KeyChord>().is_err());
assert!("F25".parse::<KeyChord>().is_err());
}
#[test]
fn display_round_trips() {
for spec in ["ctrl+shift+t", "super+F5", "alt+KP_3", "Return"] {
let c = chord(spec);
assert_eq!(chord(&c.to_string()), c, "{spec} -> {c}");
}
}
}