Files
pipewire-soundpad/src/gui/input.rs
T
RiDDiX a156df346b 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>
2026-04-06 21:43:41 +03:00

360 lines
12 KiB
Rust

use crate::gui::SoundpadGui;
use egui::{Context, Id, Key, Modifiers};
use pwsp::types::socket::Request;
use pwsp::utils::gui::make_request_async;
use std::path::PathBuf;
/// Convert an egui Key + Modifiers to a normalized chord string like "Ctrl+Shift+A".
fn chord_from_event(modifiers: &Modifiers, key: &Key) -> Option<String> {
let key_name = match key {
Key::A => "A",
Key::B => "B",
Key::C => "C",
Key::D => "D",
Key::E => "E",
Key::F => "F",
Key::G => "G",
Key::H => "H",
Key::I => "I",
Key::J => "J",
Key::K => "K",
Key::L => "L",
Key::M => "M",
Key::N => "N",
Key::O => "O",
Key::P => "P",
Key::Q => "Q",
Key::R => "R",
Key::S => "S",
Key::T => "T",
Key::U => "U",
Key::V => "V",
Key::W => "W",
Key::X => "X",
Key::Y => "Y",
Key::Z => "Z",
Key::Num0 => "0",
Key::Num1 => "1",
Key::Num2 => "2",
Key::Num3 => "3",
Key::Num4 => "4",
Key::Num5 => "5",
Key::Num6 => "6",
Key::Num7 => "7",
Key::Num8 => "8",
Key::Num9 => "9",
Key::F1 => "F1",
Key::F2 => "F2",
Key::F3 => "F3",
Key::F4 => "F4",
Key::F5 => "F5",
Key::F6 => "F6",
Key::F7 => "F7",
Key::F8 => "F8",
Key::F9 => "F9",
Key::F10 => "F10",
Key::F11 => "F11",
Key::F12 => "F12",
_ => return None,
};
// Require at least one modifier for hotkey chords (ignoring command/Super due to Wayland/Niri bug)
if !modifiers.ctrl && !modifiers.alt && !modifiers.shift {
return None;
}
let mut parts = vec![];
if modifiers.ctrl {
parts.push("Ctrl");
}
if modifiers.alt {
parts.push("Alt");
}
if modifiers.shift {
parts.push("Shift");
}
// We intentionally ignore modifiers.command (Super) here to bypass a Wayland/Niri bug
// where the Super key modifier is constantly active.
parts.push(key_name);
Some(parts.join("+"))
}
/// Parse a chord string back to (Modifiers, Key) for matching.
pub fn parse_chord(chord: &str) -> Option<(Modifiers, Key)> {
let parts: Vec<&str> = chord.split('+').collect();
if parts.is_empty() {
return None;
}
let mut modifiers = Modifiers::NONE;
for &part in &parts[..parts.len() - 1] {
match part {
"Ctrl" => modifiers.ctrl = true,
"Alt" => modifiers.alt = true,
"Shift" => modifiers.shift = true,
"Super" => modifiers.command = true,
_ => return None,
}
}
let key = match parts[parts.len() - 1] {
"A" => Key::A,
"B" => Key::B,
"C" => Key::C,
"D" => Key::D,
"E" => Key::E,
"F" => Key::F,
"G" => Key::G,
"H" => Key::H,
"I" => Key::I,
"J" => Key::J,
"K" => Key::K,
"L" => Key::L,
"M" => Key::M,
"N" => Key::N,
"O" => Key::O,
"P" => Key::P,
"Q" => Key::Q,
"R" => Key::R,
"S" => Key::S,
"T" => Key::T,
"U" => Key::U,
"V" => Key::V,
"W" => Key::W,
"X" => Key::X,
"Y" => Key::Y,
"Z" => Key::Z,
"0" => Key::Num0,
"1" => Key::Num1,
"2" => Key::Num2,
"3" => Key::Num3,
"4" => Key::Num4,
"5" => Key::Num5,
"6" => Key::Num6,
"7" => Key::Num7,
"8" => Key::Num8,
"9" => Key::Num9,
"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 None,
};
Some((modifiers, key))
}
impl SoundpadGui {
fn key_pressed(&self, ctx: &Context, key: Key) -> bool {
ctx.input(|i| i.key_pressed(key))
}
fn modifiers(&self, ctx: &Context) -> Modifiers {
ctx.input(|i| i.modifiers)
}
fn get_focused(&self, ctx: &Context) -> Option<Id> {
ctx.memory(|m| m.focused())
}
pub fn handle_input(&mut self, ctx: &Context) {
let modifiers = self.modifiers(ctx);
let search_focused = {
if let Some(focused_id) = self.get_focused(ctx)
&& let Some(search_id) = self.app_state.search_field_id
&& focused_id.eq(&search_id)
{
true
} else {
false
}
};
// Handle hotkey capture mode: listen for a key chord to assign
if self.app_state.hotkey_capture_active {
if self.key_pressed(ctx, Key::Escape) {
self.app_state.hotkey_capture_active = false;
self.app_state.assigning_hotkey_slot = None;
self.app_state.assigning_hotkey_for_file = None;
return;
}
// Try to capture a chord from any key press
let captured = ctx.input(|i| {
for event in &i.events {
if let egui::Event::Key {
key,
pressed: true,
modifiers: mods,
..
} = event
&& let Some(chord) = chord_from_event(mods, key)
{
return Some(chord);
}
}
None
});
if let Some(chord) = captured {
if let Some(slot) = self.app_state.assigning_hotkey_slot.take() {
make_request_async(Request::set_hotkey_key(&slot, &chord));
self.app_state
.hotkey_config
.set_key_chord(&slot, Some(chord));
} else if let Some(file_path) = self.app_state.assigning_hotkey_for_file.take() {
// Auto-create a slot from the file name
let slot_name = file_path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let action = Request::play(&file_path.to_string_lossy(), false);
make_request_async(Request::set_hotkey_action(&slot_name, &action));
make_request_async(Request::set_hotkey_key(&slot_name, &chord));
self.app_state
.hotkey_config
.set_slot(slot_name.clone(), action);
self.app_state
.hotkey_config
.set_key_chord(&slot_name, Some(chord));
}
self.app_state.hotkey_capture_active = false;
}
return;
}
// Open/close settings
if !search_focused && self.key_pressed(ctx, Key::I) {
self.app_state.show_settings = !self.app_state.show_settings;
}
// Toggle hotkeys view
if !search_focused && self.key_pressed(ctx, Key::H) {
self.app_state.show_hotkeys = !self.app_state.show_hotkeys;
}
if !self.app_state.show_settings && !self.app_state.show_hotkeys {
// Pause / resume audio on space
if !search_focused && self.key_pressed(ctx, Key::Space) {
self.play_toggle();
}
// Stop all audio tracks on backspace
if !search_focused && self.key_pressed(ctx, Key::Backspace) {
self.stop(None);
}
// Focus search field
if self.key_pressed(ctx, Key::Slash) {
if search_focused {
ctx.memory_mut(|m| {
m.request_focus(Id::NULL);
});
} else {
self.app_state.force_focus_search = true;
}
}
// Play selected file on Enter
if self.key_pressed(ctx, Key::Enter) {
if let Some(path) = self.app_state.selected_file.clone() {
if modifiers.ctrl {
self.play_file(&path, true);
} else if modifiers.shift
&& let Some(last_track) = self.audio_player_state.tracks.last()
{
self.stop(Some(last_track.id));
self.play_file(&path, true);
} else {
self.play_file(&path, false);
}
}
}
// Iterate through dirs and files with Ctrl + Up/Down
let arrow_up_pressed = self.key_pressed(ctx, Key::ArrowUp);
let arrow_down_pressed = self.key_pressed(ctx, Key::ArrowDown);
if modifiers.ctrl && (arrow_up_pressed || arrow_down_pressed) {
if modifiers.shift && !self.app_state.dirs.is_empty() {
let mut dirs: Vec<PathBuf> = self.app_state.dirs.iter().cloned().collect();
dirs.sort();
let current_dir_index = self
.app_state
.current_dir
.as_ref()
.and_then(|cd| dirs.iter().position(|x| x == cd));
let new_dir_index =
match (current_dir_index, arrow_up_pressed, arrow_down_pressed) {
(Some(i), true, false) => (i + dirs.len() - 1) % dirs.len(),
(Some(i), false, true) => (i + 1) % dirs.len(),
(Some(i), true, true) => i,
(None, true, _) => dirs.len() - 1,
(None, false, true) => 0,
_ => return,
};
self.open_dir(&dirs[new_dir_index]);
} else if self.app_state.current_dir.is_some() {
let files = self.get_filtered_files();
if files.is_empty() {
return;
}
let current_files_index = self
.app_state
.selected_file
.as_ref()
.and_then(|f| files.iter().position(|x| x == f));
let new_files_index =
match (current_files_index, arrow_up_pressed, arrow_down_pressed) {
(Some(i), true, false) => (i + files.len() - 1) % files.len(),
(Some(i), false, true) => (i + 1) % files.len(),
(Some(i), true, true) => i,
(None, true, _) => files.len() - 1,
(None, false, true) => 0,
_ => return,
};
self.app_state.selected_file = Some(files[new_files_index].clone());
}
}
// Check for hotkey chord triggers
let slots_to_play: Vec<String> = ctx.input(|i| {
let mut result = vec![];
for slot in &self.app_state.hotkey_config.slots {
if let Some(chord) = &slot.key_chord
&& let Some((mods, key)) = parse_chord(chord)
&& i.modifiers == mods
&& i.key_pressed(key)
{
result.push(slot.slot.clone());
}
}
result
});
for slot in slots_to_play {
self.play_hotkey_slot(&slot);
}
}
// });
}
}