mirror of
https://github.com/arabianq/pipewire-soundpad.git
synced 2026-06-19 20:23:33 +00:00
e91465365d
* add tests * update github actions to include testing step * optimization
287 lines
9.2 KiB
Rust
287 lines
9.2 KiB
Rust
use crate::gui::SoundpadGui;
|
|
use egui::{Context, Id, Key, Modifiers};
|
|
use pwsp_lib::types::socket::Request;
|
|
use pwsp_lib::utils::gui::make_request_async;
|
|
|
|
/// 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 = key.name();
|
|
let is_valid = (key_name.len() == 1
|
|
&& key_name
|
|
.chars()
|
|
.next()
|
|
.is_some_and(|c| c.is_ascii_alphanumeric()))
|
|
|| (key_name.starts_with('F')
|
|
&& key_name.len() > 1
|
|
&& key_name[1..].chars().all(|c| c.is_ascii_digit()));
|
|
if !is_valid {
|
|
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_name = parts[parts.len() - 1];
|
|
let is_valid = (key_name.len() == 1
|
|
&& key_name
|
|
.chars()
|
|
.next()
|
|
.is_some_and(|c| c.is_ascii_alphanumeric()))
|
|
|| (key_name.starts_with('F')
|
|
&& key_name.len() > 1
|
|
&& key_name[1..].chars().all(|c| c.is_ascii_digit()));
|
|
|
|
if !is_valid {
|
|
return None;
|
|
}
|
|
|
|
let key = Key::from_name(key_name)?;
|
|
|
|
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_and_key(
|
|
&slot_name, &action, &chord,
|
|
));
|
|
|
|
self.app_state
|
|
.hotkey_config
|
|
.set_slot(slot_name.clone(), action);
|
|
self.app_state
|
|
.hotkey_config
|
|
.set_key_chord(&slot_name, Some(chord.clone()));
|
|
}
|
|
self.app_state.hotkey_capture_active = false;
|
|
self.app_state.assigning_hotkey_slot = None;
|
|
self.app_state.assigning_hotkey_for_file = None;
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
// });
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use egui::{Key, Modifiers};
|
|
|
|
#[test]
|
|
fn test_chord_from_event() {
|
|
// Valid modifier + key
|
|
let mut mods = Modifiers::NONE;
|
|
mods.ctrl = true;
|
|
let chord = chord_from_event(&mods, &Key::A);
|
|
assert_eq!(chord, Some("Ctrl+A".to_string()));
|
|
|
|
// Multiple modifiers
|
|
mods.shift = true;
|
|
let chord = chord_from_event(&mods, &Key::F1);
|
|
assert_eq!(chord, Some("Ctrl+Shift+F1".to_string()));
|
|
|
|
// Missing modifiers (requires at least one modifier)
|
|
let no_mods = Modifiers::NONE;
|
|
let chord = chord_from_event(&no_mods, &Key::A);
|
|
assert_eq!(chord, None);
|
|
|
|
// Invalid keys (e.g. Escape or Enter shouldn't be accepted by chord_from_event)
|
|
mods.shift = false;
|
|
let chord = chord_from_event(&mods, &Key::Escape);
|
|
assert_eq!(chord, None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_chord() {
|
|
// Valid Ctrl+A
|
|
let res = parse_chord("Ctrl+A");
|
|
assert!(res.is_some());
|
|
let (mods, key) = res.unwrap();
|
|
assert!(mods.ctrl);
|
|
assert!(!mods.alt);
|
|
assert!(!mods.shift);
|
|
assert_eq!(key, Key::A);
|
|
|
|
// Valid Ctrl+Shift+F12
|
|
let res = parse_chord("Ctrl+Shift+F12");
|
|
assert!(res.is_some());
|
|
let (mods, key) = res.unwrap();
|
|
assert!(mods.ctrl);
|
|
assert!(mods.shift);
|
|
assert!(!mods.alt);
|
|
assert_eq!(key, Key::F12);
|
|
|
|
// Valid Ctrl+Alt+Shift+Super+B
|
|
let res = parse_chord("Ctrl+Alt+Shift+Super+B");
|
|
assert!(res.is_some());
|
|
let (mods, key) = res.unwrap();
|
|
assert!(mods.ctrl);
|
|
assert!(mods.alt);
|
|
assert!(mods.shift);
|
|
assert!(mods.command); // Super maps to command in egui Modifiers
|
|
assert_eq!(key, Key::B);
|
|
|
|
// Invalid keys/chords
|
|
assert!(parse_chord("").is_none());
|
|
assert!(parse_chord("Ctrl+").is_none());
|
|
assert!(parse_chord("Ctrl+Escape").is_none());
|
|
assert!(parse_chord("Invalid+A").is_none());
|
|
}
|
|
}
|