use std::str::FromStr;
use anyhow::{anyhow, Context, Result};
use crossbeam_channel::Receiver;
use global_hotkey::{hotkey::HotKey, GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState};
pub const DEFAULT_STOP_ACCELERATOR: &str = "CmdOrCtrl+Shift+R";
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum HotkeyEvent {
StopRequested,
}
pub struct HotkeyListener {
#[allow(dead_code)]
manager: GlobalHotKeyManager,
hotkey_id: u32,
events: Receiver<GlobalHotKeyEvent>,
}
impl HotkeyListener {
pub fn start(accelerator: &str) -> Result<Self> {
validate_accelerator_syntax(accelerator)?;
let manager = GlobalHotKeyManager::new().context("creating global hotkey manager")?;
let hotkey = HotKey::from_str(accelerator)
.with_context(|| format!("parsing hotkey accelerator '{accelerator}'"))?;
let hotkey_id = hotkey.id();
manager
.register(hotkey)
.with_context(|| format!("registering hotkey '{accelerator}'"))?;
let events = GlobalHotKeyEvent::receiver().clone();
Ok(Self {
manager,
hotkey_id,
events,
})
}
pub fn poll(&self) -> Option<HotkeyEvent> {
while let Ok(event) = self.events.try_recv() {
if event.id == self.hotkey_id && event.state == HotKeyState::Pressed {
return Some(HotkeyEvent::StopRequested);
}
}
None
}
}
fn validate_accelerator_syntax(accelerator: &str) -> Result<()> {
let trimmed = accelerator.trim();
if trimmed.is_empty() {
return Err(anyhow!("hotkey accelerator must not be empty"));
}
let segments: Vec<&str> = trimmed.split('+').map(str::trim).collect();
if segments.iter().any(|s| s.is_empty()) {
return Err(anyhow!(
"hotkey accelerator '{accelerator}' contains an empty segment"
));
}
let last = segments
.last()
.copied()
.ok_or_else(|| anyhow!("hotkey accelerator '{accelerator}' has no key segment"))?;
if is_modifier_token(last) {
return Err(anyhow!(
"hotkey accelerator '{accelerator}' must terminate in a non-modifier key"
));
}
Ok(())
}
fn is_modifier_token(token: &str) -> bool {
matches!(
token.to_ascii_lowercase().as_str(),
"shift"
| "ctrl"
| "control"
| "alt"
| "option"
| "super"
| "meta"
| "cmd"
| "command"
| "cmdorctrl"
| "commandorcontrol"
)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn test_validate_accelerator_syntax_accepts_default_stop() {
validate_accelerator_syntax(DEFAULT_STOP_ACCELERATOR).unwrap();
}
#[test]
fn test_validate_accelerator_syntax_accepts_alt_function_key() {
validate_accelerator_syntax("Alt+F4").unwrap();
}
#[test]
fn test_validate_accelerator_syntax_accepts_super_space() {
validate_accelerator_syntax("Super+Space").unwrap();
}
#[test]
fn test_validate_accelerator_syntax_rejects_empty_string() {
let err = validate_accelerator_syntax("").unwrap_err();
assert!(err.to_string().contains("must not be empty"));
}
#[test]
fn test_validate_accelerator_syntax_rejects_pure_modifier_chord() {
let err = validate_accelerator_syntax("Ctrl+Shift").unwrap_err();
assert!(err.to_string().contains("non-modifier"));
}
#[test]
fn test_validate_accelerator_syntax_rejects_empty_segment() {
let err = validate_accelerator_syntax("Ctrl++R").unwrap_err();
assert!(err.to_string().contains("empty segment"));
}
#[test]
fn test_is_modifier_token_recognises_cmdorctrl_alias() {
assert!(is_modifier_token("CmdOrCtrl"));
}
#[test]
fn test_is_modifier_token_rejects_letter_key() {
assert!(!is_modifier_token("R"));
}
#[test]
fn test_default_stop_accelerator_constant_validates_as_a_well_formed_accelerator() {
validate_accelerator_syntax(DEFAULT_STOP_ACCELERATOR).unwrap();
assert!(DEFAULT_STOP_ACCELERATOR.contains('+'));
}
}