mirror of
https://github.com/arabianq/pipewire-soundpad.git
synced 2026-04-28 06:21:23 +00:00
feat: add hotkey system (#48)
* feat: add hotkey system for playing individual sounds Slot-based hotkey mappings stored in ~/.config/pwsp/hotkeys.json. Daemon serves hotkey IPC commands for CLI/compositor bindings. GUI supports focused hotkey triggers, a dedicated Hotkeys panel with search and conflict detection, file badges, and a key chord capture dialog. CLI gains play-hotkey, get hotkeys, set hotkey, set hotkey-key, and clear-hotkey subcommands. * feat: add global hotkey support via evdev Listen for keyboard events directly from /dev/input using evdev, enabling hotkeys to work system-wide regardless of window focus or display server (X11, GNOME, KDE Plasma, Hyprland). The daemon spawns async listeners for each keyboard device at startup, tracks modifier state, and triggers playback when a configured chord matches. Requires the user to be in the 'input' group; logs a warning and continues without global hotkeys if devices are inaccessible. * various changes * refactor: route hotkey mutations through daemon IPC GUI no longer writes hotkey config directly to disk. Instead, all mutations (set slot, set key chord, clear chord, remove slot) are sent to the daemon via IPC, which persists the changes. The state thread periodically syncs the hotkey config back from the daemon, so CLI-made changes are reflected in the GUI. New IPC commands: set_hotkey_action (arbitrary action per slot), clear_hotkey_key (remove key chord without removing the slot). Also removes unreachable capture overlay from draw_hotkeys(). * small refactor --------- Co-authored-by: arabian <a.tevg@ya.ru>
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
use crate::{types::config::HotkeyConfig, utils::commands::parse_command};
|
||||
use evdev::{Device, EventStream, EventSummary, KeyCode};
|
||||
|
||||
struct ModifierState {
|
||||
ctrl: bool,
|
||||
alt: bool,
|
||||
shift: bool,
|
||||
meta: bool,
|
||||
}
|
||||
|
||||
impl ModifierState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
shift: false,
|
||||
meta: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, key: KeyCode, pressed: bool) {
|
||||
match key {
|
||||
KeyCode::KEY_LEFTCTRL | KeyCode::KEY_RIGHTCTRL => self.ctrl = pressed,
|
||||
KeyCode::KEY_LEFTALT | KeyCode::KEY_RIGHTALT => self.alt = pressed,
|
||||
KeyCode::KEY_LEFTSHIFT | KeyCode::KEY_RIGHTSHIFT => self.shift = pressed,
|
||||
KeyCode::KEY_LEFTMETA | KeyCode::KEY_RIGHTMETA => self.meta = pressed,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn any_active(&self) -> bool {
|
||||
self.ctrl || self.alt || self.shift || self.meta
|
||||
}
|
||||
|
||||
fn is_modifier(key: KeyCode) -> bool {
|
||||
matches!(
|
||||
key,
|
||||
KeyCode::KEY_LEFTCTRL
|
||||
| KeyCode::KEY_RIGHTCTRL
|
||||
| KeyCode::KEY_LEFTALT
|
||||
| KeyCode::KEY_RIGHTALT
|
||||
| KeyCode::KEY_LEFTSHIFT
|
||||
| KeyCode::KEY_RIGHTSHIFT
|
||||
| KeyCode::KEY_LEFTMETA
|
||||
| KeyCode::KEY_RIGHTMETA
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn evdev_key_name(key: KeyCode) -> Option<&'static str> {
|
||||
match key {
|
||||
KeyCode::KEY_A => Some("A"),
|
||||
KeyCode::KEY_B => Some("B"),
|
||||
KeyCode::KEY_C => Some("C"),
|
||||
KeyCode::KEY_D => Some("D"),
|
||||
KeyCode::KEY_E => Some("E"),
|
||||
KeyCode::KEY_F => Some("F"),
|
||||
KeyCode::KEY_G => Some("G"),
|
||||
KeyCode::KEY_H => Some("H"),
|
||||
KeyCode::KEY_I => Some("I"),
|
||||
KeyCode::KEY_J => Some("J"),
|
||||
KeyCode::KEY_K => Some("K"),
|
||||
KeyCode::KEY_L => Some("L"),
|
||||
KeyCode::KEY_M => Some("M"),
|
||||
KeyCode::KEY_N => Some("N"),
|
||||
KeyCode::KEY_O => Some("O"),
|
||||
KeyCode::KEY_P => Some("P"),
|
||||
KeyCode::KEY_Q => Some("Q"),
|
||||
KeyCode::KEY_R => Some("R"),
|
||||
KeyCode::KEY_S => Some("S"),
|
||||
KeyCode::KEY_T => Some("T"),
|
||||
KeyCode::KEY_U => Some("U"),
|
||||
KeyCode::KEY_V => Some("V"),
|
||||
KeyCode::KEY_W => Some("W"),
|
||||
KeyCode::KEY_X => Some("X"),
|
||||
KeyCode::KEY_Y => Some("Y"),
|
||||
KeyCode::KEY_Z => Some("Z"),
|
||||
KeyCode::KEY_1 => Some("1"),
|
||||
KeyCode::KEY_2 => Some("2"),
|
||||
KeyCode::KEY_3 => Some("3"),
|
||||
KeyCode::KEY_4 => Some("4"),
|
||||
KeyCode::KEY_5 => Some("5"),
|
||||
KeyCode::KEY_6 => Some("6"),
|
||||
KeyCode::KEY_7 => Some("7"),
|
||||
KeyCode::KEY_8 => Some("8"),
|
||||
KeyCode::KEY_9 => Some("9"),
|
||||
KeyCode::KEY_0 => Some("0"),
|
||||
KeyCode::KEY_F1 => Some("F1"),
|
||||
KeyCode::KEY_F2 => Some("F2"),
|
||||
KeyCode::KEY_F3 => Some("F3"),
|
||||
KeyCode::KEY_F4 => Some("F4"),
|
||||
KeyCode::KEY_F5 => Some("F5"),
|
||||
KeyCode::KEY_F6 => Some("F6"),
|
||||
KeyCode::KEY_F7 => Some("F7"),
|
||||
KeyCode::KEY_F8 => Some("F8"),
|
||||
KeyCode::KEY_F9 => Some("F9"),
|
||||
KeyCode::KEY_F10 => Some("F10"),
|
||||
KeyCode::KEY_F11 => Some("F11"),
|
||||
KeyCode::KEY_F12 => Some("F12"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_chord(modifiers: &ModifierState, key_name: &str) -> String {
|
||||
let mut parts = Vec::with_capacity(5);
|
||||
if modifiers.ctrl {
|
||||
parts.push("Ctrl");
|
||||
}
|
||||
if modifiers.alt {
|
||||
parts.push("Alt");
|
||||
}
|
||||
if modifiers.shift {
|
||||
parts.push("Shift");
|
||||
}
|
||||
if modifiers.meta {
|
||||
parts.push("Super");
|
||||
}
|
||||
parts.push(key_name);
|
||||
parts.join("+")
|
||||
}
|
||||
|
||||
fn is_keyboard(device: &Device) -> bool {
|
||||
device
|
||||
.supported_keys()
|
||||
.is_some_and(|keys| keys.contains(KeyCode::KEY_A) && keys.contains(KeyCode::KEY_Z))
|
||||
}
|
||||
|
||||
async fn handle_device_events(mut stream: EventStream) {
|
||||
let mut modifiers = ModifierState::new();
|
||||
|
||||
loop {
|
||||
match stream.next_event().await {
|
||||
Ok(event) => {
|
||||
if let EventSummary::Key(_, key, value) = event.destructure() {
|
||||
// 0 = released, 1 = pressed, 2 = repeat
|
||||
if value == 0 || value == 1 {
|
||||
modifiers.update(key, value == 1);
|
||||
}
|
||||
|
||||
// Only trigger on press, skip modifiers and bare keys
|
||||
if value != 1 || ModifierState::is_modifier(key) || !modifiers.any_active() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(key_name) = evdev_key_name(key) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let chord = build_chord(&modifiers, key_name);
|
||||
|
||||
let config = match HotkeyConfig::load() {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let slots = config.slots_for_chord(&chord);
|
||||
for slot in slots {
|
||||
if let Some(cmd) = parse_command(&slot.action) {
|
||||
cmd.execute().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Global hotkeys: device read error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_global_hotkey_listener() {
|
||||
let keyboards: Vec<_> = evdev::enumerate()
|
||||
.filter(|(_, dev)| is_keyboard(dev))
|
||||
.collect();
|
||||
|
||||
if keyboards.is_empty() {
|
||||
eprintln!(
|
||||
"Global hotkeys: no keyboard devices found. \
|
||||
Make sure your user is in the 'input' group."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
println!(
|
||||
"Global hotkeys: found {} keyboard device(s)",
|
||||
keyboards.len()
|
||||
);
|
||||
|
||||
for (path, device) in keyboards {
|
||||
match device.into_event_stream() {
|
||||
Ok(stream) => {
|
||||
println!("Global hotkeys: listening on {}", path.display());
|
||||
tokio::spawn(handle_device_events(stream));
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Global hotkeys: failed to open {}: {}", path.display(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user