#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Chord {
pub shift: bool,
pub ctrl: bool,
pub alt: bool,
pub super_: bool,
pub key: String,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum ChordError {
#[error("chord is empty")]
Empty,
#[error("chord has no key after the modifiers")]
NoKey,
#[error("unknown modifier: {0}")]
UnknownModifier(String),
}
impl Chord {
pub fn parse(s: &str) -> Result<Self, ChordError> {
let trimmed = s.trim();
if trimmed.is_empty() {
return Err(ChordError::Empty);
}
let mut parts: Vec<&str> = trimmed.split('+').map(str::trim).collect();
let Some(key) = parts.pop() else {
return Err(ChordError::Empty);
};
if key.is_empty() {
return Err(ChordError::NoKey);
}
let mut chord = Self {
key: key.to_ascii_uppercase(),
..Self::default()
};
for part in parts {
if part.is_empty() {
continue;
}
match part.to_ascii_uppercase().as_str() {
"SHIFT" => chord.shift = true,
"CTRL" | "CONTROL" => chord.ctrl = true,
"ALT" | "OPTION" => chord.alt = true,
"SUPER" | "META" | "CMD" | "COMMAND" | "WIN" | "WINDOWS" => chord.super_ = true,
other => return Err(ChordError::UnknownModifier(other.to_string())),
}
}
Ok(chord)
}
pub fn hyprland_modifiers(&self) -> String {
let mut parts: Vec<&str> = Vec::new();
if self.ctrl {
parts.push("CTRL");
}
if self.shift {
parts.push("SHIFT");
}
if self.alt {
parts.push("ALT");
}
if self.super_ {
parts.push("SUPER");
}
parts.join(" ")
}
pub fn hyprland_key(&self) -> &str {
match self.key.as_str() {
"ESC" => "Escape",
"ENTER" => "Return",
_ => &self.key,
}
}
pub fn modifiers_match(&self, ctrl: bool, alt: bool, shift: bool, super_: bool) -> bool {
self.ctrl == ctrl && self.alt == alt && self.shift == shift && self.super_ == super_
}
}
impl std::fmt::Display for Chord {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut parts: Vec<&str> = Vec::new();
if self.ctrl {
parts.push("CTRL");
}
if self.shift {
parts.push("SHIFT");
}
if self.alt {
parts.push("ALT");
}
if self.super_ {
parts.push("SUPER");
}
if parts.is_empty() {
f.write_str(&self.key)
} else {
write!(f, "{}+{}", parts.join("+"), self.key)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_full_chord() {
let c = Chord::parse("SUPER+CTRL+SHIFT+ALT+F").unwrap();
assert!(c.super_ && c.ctrl && c.shift && c.alt);
assert_eq!(c.key, "F");
}
#[test]
fn parses_unmodified_chord() {
let c = Chord::parse("F1").unwrap();
assert!(!c.super_ && !c.ctrl && !c.shift && !c.alt);
assert_eq!(c.key, "F1");
}
#[test]
fn parses_lowercase_and_synonyms() {
let c = Chord::parse("meta+ctrl+space").unwrap();
assert!(c.super_ && c.ctrl);
assert_eq!(c.key, "SPACE");
}
#[test]
fn display_uses_canonical_order() {
let c = Chord::parse("SUPER+CTRL+ALT+J").unwrap();
assert_eq!(c.to_string(), "CTRL+ALT+SUPER+J");
let c = Chord::parse("SUPER+CTRL+SHIFT+ALT+F").unwrap();
assert_eq!(c.to_string(), "CTRL+SHIFT+ALT+SUPER+F");
}
#[test]
fn rejects_empty_and_unknown() {
assert_eq!(Chord::parse(""), Err(ChordError::Empty));
assert_eq!(Chord::parse(" "), Err(ChordError::Empty));
assert_eq!(
Chord::parse("FOO+F"),
Err(ChordError::UnknownModifier("FOO".into()))
);
}
#[test]
fn hyprland_modifiers_uses_spaces_not_pluses() {
let c = Chord::parse("SUPER+CTRL+SHIFT+ALT+F").unwrap();
assert_eq!(c.hyprland_modifiers(), "CTRL SHIFT ALT SUPER");
assert_eq!(c.hyprland_key(), "F");
}
}