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:
RiDDiX
2026-04-06 20:43:41 +02:00
committed by GitHub
parent 7a13ae55a6
commit a156df346b
16 changed files with 1373 additions and 79 deletions
+415 -68
View File
@@ -1,13 +1,17 @@
use crate::gui::SoundpadGui;
use egui::{
Align, AtomExt, Button, CollapsingHeader, Color32, ComboBox, CursorIcon, FontFamily, Label,
Layout, RichText, ScrollArea, Sense, Slider, TextEdit, Ui, Vec2,
Align, AtomExt, Button, CollapsingHeader, Color32, ComboBox, CursorIcon, FontFamily, Grid,
Label, Layout, RichText, ScrollArea, Sense, Slider, TextEdit, Ui, Vec2,
};
use egui_dnd::dnd;
use egui_material_icons::icons::*;
use pwsp::types::socket::Request;
use pwsp::types::{audio_player::TrackInfo, gui::AppState};
use pwsp::utils::gui::format_time_pair;
use std::{error::Error, time::Instant};
use pwsp::utils::gui::{format_time_pair, make_request_async};
use std::{
path::{Path, PathBuf},
time::Instant,
};
enum TrackAction {
Pause(u32),
@@ -16,6 +20,13 @@ enum TrackAction {
Stop(u32),
}
enum HotkeyAction {
Remove(String),
Capture(String),
ClearChord(String),
Play(String),
}
impl SoundpadGui {
fn get_volume_icon(volume: f32) -> &'static str {
if volume > 0.7 {
@@ -29,6 +40,13 @@ impl SoundpadGui {
}
}
pub fn draw(&mut self, ui: &mut Ui) {
self.draw_header(ui);
self.draw_body(ui);
ui.separator();
self.draw_footer(ui);
}
pub fn draw_waiting_for_daemon(&mut self, ui: &mut Ui) {
ui.centered_and_justified(|ui| {
ui.label(
@@ -39,6 +57,32 @@ impl SoundpadGui {
});
}
pub fn draw_hotkey_capture(&mut self, ui: &mut Ui) {
ui.vertical_centered(|ui| {
ui.add_space(ui.available_height() / 3.0);
ui.label(
RichText::new("Press a key combination (e.g. Ctrl+Alt+1)")
.size(18.0)
.color(Color32::YELLOW)
.monospace(),
);
ui.add_space(10.0);
let target = if let Some(slot) = &self.app_state.assigning_hotkey_slot {
format!("for slot '{}'", slot)
} else if let Some(path) = &self.app_state.assigning_hotkey_for_file {
format!(
"for '{}'",
path.file_name().unwrap_or_default().to_string_lossy()
)
} else {
String::new()
};
ui.label(RichText::new(target).size(16.0));
ui.add_space(10.0);
ui.label("Press Escape to cancel");
});
}
pub fn draw_settings(&mut self, ui: &mut Ui) {
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 5.0;
@@ -88,12 +132,256 @@ impl SoundpadGui {
});
}
pub fn draw(&mut self, ui: &mut Ui) -> Result<(), Box<dyn Error>> {
self.draw_header(ui);
self.draw_body(ui);
ui.separator();
self.draw_footer(ui);
Ok(())
pub fn draw_hotkeys(&mut self, ui: &mut Ui) {
let area_size = ui.available_size();
ui.vertical(|ui| {
ui.set_min_width(area_size.x);
ui.set_min_height(area_size.y);
ui.spacing_mut().item_spacing.y = 5.0;
// Header
ui.horizontal_top(|ui| {
let back_button = Button::new(ICON_ARROW_BACK).frame(false);
if ui.add(back_button).clicked() {
self.app_state.show_hotkeys = false;
}
ui.add_space(ui.available_width() / 2.0 - 40.0);
ui.label(RichText::new("Hotkeys").color(Color32::WHITE).monospace());
});
ui.separator();
// Search and Add Command
ui.horizontal(|ui| {
ui.menu_button(format!("{} Add Command", ICON_ADD.codepoint), |ui| {
let mut selected_cmd = None;
if ui.button("Toggle Pause").clicked() {
selected_cmd = Some(("cmd_toggle_pause", Request::toggle_pause(None)));
}
if ui.button("Stop Playback").clicked() {
selected_cmd = Some(("cmd_stop", Request::stop(None)));
}
if ui.button("Pause Playback").clicked() {
selected_cmd = Some(("cmd_pause", Request::pause(None)));
}
if ui.button("Resume Playback").clicked() {
selected_cmd = Some(("cmd_resume", Request::resume(None)));
}
if ui.button("Toggle Loop").clicked() {
selected_cmd = Some(("cmd_toggle_loop", Request::toggle_loop(None)));
}
if let Some((slot_name, req)) = selected_cmd {
make_request_async(Request::set_hotkey_action(slot_name, &req));
self.app_state
.hotkey_config
.set_slot(slot_name.to_string(), req);
self.app_state.assigning_hotkey_slot = Some(slot_name.to_string());
self.app_state.hotkey_capture_active = true;
ui.close();
}
});
ui.add_space(10.0);
ui.add_sized(
[ui.available_width(), 22.0],
TextEdit::singleline(&mut self.app_state.hotkey_search_query)
.hint_text("Search hotkeys..."),
);
});
ui.separator();
ui.add_space(5.0);
let conflicts = self.app_state.hotkey_config.find_conflicts();
let conflict_slots: std::collections::HashSet<String> = conflicts
.iter()
.flat_map(|(a, b)| vec![a.clone(), b.clone()])
.collect();
let search = self.app_state.hotkey_search_query.to_lowercase();
// Slots table
let mut action: Option<HotkeyAction> = None;
let area_size = ui.available_size();
ScrollArea::vertical().show(ui, |ui| {
ui.set_min_width(area_size.x);
Grid::new("hotkeys_grid")
.striped(true)
.num_columns(4)
.max_col_width(area_size.x)
.min_col_width(area_size.x / 4.0)
.spacing([40.0, 10.0])
.show(ui, |ui| {
// Table header
ui.label(
RichText::new("Slot")
.strong()
.monospace()
.color(Color32::LIGHT_GRAY),
);
ui.label(
RichText::new("Sound")
.strong()
.monospace()
.color(Color32::LIGHT_GRAY),
);
ui.label(
RichText::new("Key Chord")
.strong()
.monospace()
.color(Color32::LIGHT_GRAY),
);
ui.label(
RichText::new("Actions")
.strong()
.monospace()
.color(Color32::LIGHT_GRAY),
);
ui.end_row();
let slots: Vec<_> = self
.app_state
.hotkey_config
.slots
.iter()
.filter(|s| {
if search.is_empty() {
return true;
}
s.slot.to_lowercase().contains(&search)
|| format!("{:?}", s.action).to_lowercase().contains(&search)
|| s.key_chord
.as_deref()
.unwrap_or("")
.to_lowercase()
.contains(&search)
})
.cloned()
.collect();
for slot in &slots {
ui.horizontal(|ui| {
// Conflict badge
if conflict_slots.contains(&slot.slot) {
ui.label(
RichText::new(ICON_WARNING.codepoint)
.color(Color32::from_rgb(255, 165, 0)),
)
.on_hover_text("Key chord conflict");
}
// Slot name
let slot_text = RichText::new(&slot.slot).monospace();
ui.label(slot_text);
});
// Action description
let action_name = match slot.action.name.as_str() {
"play" => {
if let Some(file_path_str) = slot.action.args.get("file_path") {
Path::new(file_path_str)
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string()
} else {
"Play".to_string()
}
}
"toggle_pause" => "Toggle Pause".to_string(),
"pause" => "Pause Playback".to_string(),
"resume" => "Resume Playback".to_string(),
"stop" => "Stop Playback".to_string(),
"toggle_loop" => "Toggle Loop".to_string(),
other => other.to_string(),
};
ui.add(Label::new(RichText::new(action_name).monospace()).truncate());
// Key chord
let chord_text = slot.key_chord.as_deref().unwrap_or("(none)");
ui.label(RichText::new(chord_text).monospace().color(
if slot.key_chord.is_some() {
Color32::from_rgb(100, 200, 100)
} else {
Color32::GRAY
},
));
ui.horizontal(|ui| {
// Delete button
if ui
.add(Button::new(ICON_DELETE).frame(false))
.on_hover_text("Remove slot")
.clicked()
{
action = Some(HotkeyAction::Remove(slot.slot.clone()));
}
// Set key chord button
if ui
.add(Button::new(ICON_KEYBOARD).frame(false))
.on_hover_text("Set key chord")
.clicked()
{
action = Some(HotkeyAction::Capture(slot.slot.clone()));
}
// Clear key chord
if slot.key_chord.is_some()
&& ui
.add(Button::new(ICON_BACKSPACE).frame(false))
.on_hover_text("Clear key chord")
.clicked()
{
action = Some(HotkeyAction::ClearChord(slot.slot.clone()));
}
// Play button
if ui
.add(Button::new(ICON_PLAY_ARROW).frame(false))
.on_hover_text("Play")
.clicked()
{
action = Some(HotkeyAction::Play(slot.slot.clone()));
}
});
ui.end_row();
}
if slots.is_empty() {
ui.label("No hotkey slots configured.");
ui.label("");
ui.label("");
ui.label("");
ui.end_row();
}
});
});
if let Some(action) = action {
match action {
HotkeyAction::Remove(slot) => {
make_request_async(Request::clear_hotkey(&slot));
self.app_state.hotkey_config.remove_slot(&slot);
}
HotkeyAction::Capture(slot) => {
self.app_state.assigning_hotkey_slot = Some(slot);
self.app_state.hotkey_capture_active = true;
}
HotkeyAction::ClearChord(slot) => {
make_request_async(Request::clear_hotkey_key(&slot));
self.app_state.hotkey_config.set_key_chord(&slot, None);
}
HotkeyAction::Play(slot) => {
self.play_hotkey_slot(&slot);
}
}
}
});
}
fn draw_header(&mut self, ui: &mut Ui) {
@@ -427,72 +715,104 @@ impl SoundpadGui {
.to_string_lossy()
.to_string();
let mut file_button_text = RichText::new(file_name);
if let Some(current_file) = &self.app_state.selected_file {
if current_file.eq(&entry_path) {
file_button_text = file_button_text.color(Color32::WHITE);
ui.horizontal(|ui| {
// Hotkey badge
let hotkey_badge = self.get_hotkey_badge(&entry_path);
if let Some(badge) = &hotkey_badge {
ui.label(
RichText::new(badge)
.small()
.monospace()
.color(Color32::from_rgb(100, 200, 100)),
);
}
}
let file_button = Button::new(file_button_text).frame(false);
let file_button_response = ui.add(file_button);
if file_button_response.clicked() {
ui.input(|i| {
if i.modifiers.ctrl {
let mut file_button_text = RichText::new(&file_name);
if let Some(current_file) = &self.app_state.selected_file {
if current_file.eq(&entry_path) {
file_button_text = file_button_text.color(Color32::WHITE);
}
}
let file_button = Button::new(file_button_text).frame(false);
let file_button_response = ui.add(file_button);
if file_button_response.clicked() {
ui.input(|i| {
if i.modifiers.ctrl {
self.play_file(&entry_path, true);
} else if i.modifiers.shift
&& let Some(last_track) =
self.audio_player_state.tracks.last()
{
self.stop(Some(last_track.id));
self.play_file(&entry_path, true);
} else {
self.play_file(&entry_path, false);
}
});
self.app_state.selected_file = Some(entry_path.clone());
}
// Context menu
file_button_response.context_menu(|ui| {
if ui
.button(format!("{} {}", ICON_BOLT.codepoint, "Play Solo"))
.clicked()
{
self.play_file(&entry_path, false);
self.app_state.selected_file = Some(entry_path.clone());
}
if ui
.button(format!("{} {}", ICON_ADD.codepoint, "Add New"))
.clicked()
{
self.play_file(&entry_path, true);
} else if i.modifiers.shift
self.app_state.selected_file = Some(entry_path.clone());
}
if ui
.button(format!(
"{} {}",
ICON_SWAP_HORIZ.codepoint, "Replace Last"
))
.clicked()
&& let Some(last_track) = self.audio_player_state.tracks.last()
{
self.stop(Some(last_track.id));
self.play_file(&entry_path, true);
} else {
self.play_file(&entry_path, false);
self.app_state.selected_file = Some(entry_path.clone());
}
ui.separator();
if ui
.button(format!(
"{} {}",
ICON_OPEN_IN_BROWSER.codepoint, "Show in File Manager"
))
.clicked()
{
if let Err(e) = opener::reveal(&entry_path) {
eprintln!("Failed to open file manager: {}", e);
}
}
ui.separator();
if ui
.button(format!(
"{} {}",
ICON_KEYBOARD.codepoint, "Assign Hotkey"
))
.clicked()
{
self.app_state.assigning_hotkey_for_file =
Some(entry_path.clone());
self.app_state.hotkey_capture_active = true;
ui.close();
}
});
self.app_state.selected_file = Some(entry_path.clone());
}
// Context menu
file_button_response.context_menu(|ui| {
if ui
.button(format!("{} {}", ICON_BOLT.codepoint, "Play Solo"))
.clicked()
{
self.play_file(&entry_path, false);
self.app_state.selected_file = Some(entry_path.clone());
}
if ui
.button(format!("{} {}", ICON_ADD.codepoint, "Add New"))
.clicked()
{
self.play_file(&entry_path, true);
self.app_state.selected_file = Some(entry_path.clone());
}
if ui
.button(format!("{} {}", ICON_SWAP_HORIZ.codepoint, "Replace Last"))
.clicked()
&& let Some(last_track) = self.audio_player_state.tracks.last()
{
self.stop(Some(last_track.id));
self.play_file(&entry_path, true);
self.app_state.selected_file = Some(entry_path.clone());
}
ui.separator();
if ui
.button(format!(
"{} {}",
ICON_OPEN_IN_BROWSER.codepoint, "Show in File Manager"
))
.clicked()
{
if let Err(e) = opener::reveal(&entry_path) {
eprintln!("Failed to open file manager: {}", e);
}
}
});
}
});
@@ -500,6 +820,23 @@ impl SoundpadGui {
});
}
fn get_hotkey_badge(&self, path: &PathBuf) -> Option<String> {
for slot in &self.app_state.hotkey_config.slots {
if slot.action.name == "play" {
if let Some(file_path_str) = slot.action.args.get("file_path") {
if Path::new(file_path_str) == path.as_path() {
if let Some(chord) = &slot.key_chord {
return Some(format!("[{}]", chord));
} else {
return Some(format!("[{}]", slot.slot));
}
}
}
}
}
None
}
fn draw_footer(&mut self, ui: &mut Ui) {
ui.add_space(5.0);
ui.horizontal(|ui| {
@@ -556,7 +893,17 @@ impl SoundpadGui {
}
// ------------------------------------------
ui.add_space(ui.available_width() - 18.0 - ui.spacing().item_spacing.x);
ui.add_space(ui.available_width() - 18.0 * 2.0 - ui.spacing().item_spacing.x * 2.0);
// ---------- Hotkeys button ----------
let hotkeys_button =
Button::new(ICON_KEYBOARD.atom_size(Vec2::new(18.0, 18.0))).frame(false);
let hotkeys_button_response = ui.add_sized([18.0, 18.0], hotkeys_button);
if hotkeys_button_response.clicked() {
self.app_state.show_hotkeys = true;
}
hotkeys_button_response.on_hover_text("Hotkeys (H)");
// --------------------------------
// ---------- Settings button ----------
let settings_button =