Add audio controls and focused hotkeys
This commit is contained in:
+284
@@ -0,0 +1,284 @@
|
||||
//! Focused, app-local keyboard shortcuts.
|
||||
//!
|
||||
//! These helpers are intentionally pure: key serialization, formatting, lookup,
|
||||
//! and conflict detection live here, while iced event handling stays at the app
|
||||
//! edge. There are no OS-global shortcuts.
|
||||
|
||||
use iced::keyboard;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A serializable key identity. Modifiers are deliberately out of scope for this
|
||||
/// first pass; iced delivers the focused app key and we compare that exact key.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum KeyBinding {
|
||||
Named(String),
|
||||
Character(String),
|
||||
}
|
||||
|
||||
impl KeyBinding {
|
||||
pub fn from_key(key: &keyboard::Key) -> Option<Self> {
|
||||
match key {
|
||||
keyboard::Key::Named(named) => Some(Self::Named(format!("{named:?}"))),
|
||||
keyboard::Key::Character(ch) => {
|
||||
let s = ch.to_string();
|
||||
if s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Self::Character(s.to_lowercase()))
|
||||
}
|
||||
}
|
||||
keyboard::Key::Unidentified => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
KeyBinding::Named(name) => name.clone(),
|
||||
KeyBinding::Character(ch) => ch.to_uppercase(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a hand-editable binding string from config/docs/tests. Empty and
|
||||
/// `"unset"` are unbound.
|
||||
pub fn parse_binding(input: &str) -> Option<KeyBinding> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("unset") {
|
||||
return None;
|
||||
}
|
||||
if trimmed.chars().count() == 1 {
|
||||
Some(KeyBinding::Character(trimmed.to_lowercase()))
|
||||
} else {
|
||||
Some(KeyBinding::Named(trimmed.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_binding(binding: Option<&KeyBinding>) -> String {
|
||||
binding
|
||||
.map(KeyBinding::label)
|
||||
.unwrap_or_else(|| "unset".to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum HotkeyAction {
|
||||
ToggleMute,
|
||||
ToggleDeafen,
|
||||
OpenSettings,
|
||||
PushToTalk,
|
||||
LeaveRoom,
|
||||
}
|
||||
|
||||
impl HotkeyAction {
|
||||
pub const ALL: [HotkeyAction; 5] = [
|
||||
HotkeyAction::ToggleMute,
|
||||
HotkeyAction::ToggleDeafen,
|
||||
HotkeyAction::OpenSettings,
|
||||
HotkeyAction::PushToTalk,
|
||||
HotkeyAction::LeaveRoom,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
HotkeyAction::ToggleMute => "Toggle mute",
|
||||
HotkeyAction::ToggleDeafen => "Toggle deafen",
|
||||
HotkeyAction::OpenSettings => "Open Settings",
|
||||
HotkeyAction::PushToTalk => "Push-to-talk",
|
||||
HotkeyAction::LeaveRoom => "Leave room",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tier(self) -> HotkeyTier {
|
||||
match self {
|
||||
HotkeyAction::ToggleMute
|
||||
| HotkeyAction::ToggleDeafen
|
||||
| HotkeyAction::OpenSettings => HotkeyTier::AppWide,
|
||||
HotkeyAction::PushToTalk | HotkeyAction::LeaveRoom => HotkeyTier::RoomOnly,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HotkeyTier {
|
||||
AppWide,
|
||||
RoomOnly,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct HotkeyContext {
|
||||
pub in_call: bool,
|
||||
}
|
||||
|
||||
impl HotkeyContext {
|
||||
fn allows(self, action: HotkeyAction) -> bool {
|
||||
matches!(action.tier(), HotkeyTier::AppWide) || self.in_call
|
||||
}
|
||||
}
|
||||
|
||||
/// Persisted shortcut map. Defaults preserve the old Space push-to-talk binding
|
||||
/// and add a few function-key app shortcuts that do not collide with typing.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct HotkeyMap {
|
||||
#[serde(default = "default_mute")]
|
||||
pub toggle_mute: Option<KeyBinding>,
|
||||
#[serde(default = "default_deafen")]
|
||||
pub toggle_deafen: Option<KeyBinding>,
|
||||
#[serde(default = "default_settings")]
|
||||
pub open_settings: Option<KeyBinding>,
|
||||
#[serde(default = "default_ptt")]
|
||||
pub push_to_talk: Option<KeyBinding>,
|
||||
#[serde(default)]
|
||||
pub leave_room: Option<KeyBinding>,
|
||||
}
|
||||
|
||||
impl Default for HotkeyMap {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
toggle_mute: default_mute(),
|
||||
toggle_deafen: default_deafen(),
|
||||
open_settings: default_settings(),
|
||||
push_to_talk: default_ptt(),
|
||||
leave_room: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn named(name: &str) -> Option<KeyBinding> {
|
||||
Some(KeyBinding::Named(name.to_string()))
|
||||
}
|
||||
|
||||
fn default_mute() -> Option<KeyBinding> {
|
||||
named("F9")
|
||||
}
|
||||
|
||||
fn default_deafen() -> Option<KeyBinding> {
|
||||
named("F10")
|
||||
}
|
||||
|
||||
fn default_settings() -> Option<KeyBinding> {
|
||||
named("F2")
|
||||
}
|
||||
|
||||
fn default_ptt() -> Option<KeyBinding> {
|
||||
named("Space")
|
||||
}
|
||||
|
||||
impl HotkeyMap {
|
||||
pub fn binding(&self, action: HotkeyAction) -> Option<&KeyBinding> {
|
||||
match action {
|
||||
HotkeyAction::ToggleMute => self.toggle_mute.as_ref(),
|
||||
HotkeyAction::ToggleDeafen => self.toggle_deafen.as_ref(),
|
||||
HotkeyAction::OpenSettings => self.open_settings.as_ref(),
|
||||
HotkeyAction::PushToTalk => self.push_to_talk.as_ref(),
|
||||
HotkeyAction::LeaveRoom => self.leave_room.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_binding(&mut self, action: HotkeyAction, binding: Option<KeyBinding>) {
|
||||
match action {
|
||||
HotkeyAction::ToggleMute => self.toggle_mute = binding,
|
||||
HotkeyAction::ToggleDeafen => self.toggle_deafen = binding,
|
||||
HotkeyAction::OpenSettings => self.open_settings = binding,
|
||||
HotkeyAction::PushToTalk => self.push_to_talk = binding,
|
||||
HotkeyAction::LeaveRoom => self.leave_room = binding,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn lookup_key(&self, key: &keyboard::Key, context: HotkeyContext) -> Option<HotkeyAction> {
|
||||
let pressed = KeyBinding::from_key(key)?;
|
||||
HotkeyAction::ALL
|
||||
.into_iter()
|
||||
.find(|&action| context.allows(action) && self.binding(action) == Some(&pressed))
|
||||
}
|
||||
|
||||
pub fn lookup_binding(
|
||||
&self,
|
||||
binding: &KeyBinding,
|
||||
context: HotkeyContext,
|
||||
) -> Option<HotkeyAction> {
|
||||
HotkeyAction::ALL
|
||||
.into_iter()
|
||||
.find(|&action| context.allows(action) && self.binding(action) == Some(binding))
|
||||
}
|
||||
|
||||
pub fn conflicts(&self) -> Vec<HotkeyConflict> {
|
||||
let mut conflicts = Vec::new();
|
||||
let actions = HotkeyAction::ALL;
|
||||
for i in 0..actions.len() {
|
||||
for j in (i + 1)..actions.len() {
|
||||
let a = actions[i];
|
||||
let b = actions[j];
|
||||
if let (Some(ab), Some(bb)) = (self.binding(a), self.binding(b))
|
||||
&& ab == bb
|
||||
{
|
||||
conflicts.push(HotkeyConflict {
|
||||
binding: ab.clone(),
|
||||
first: a,
|
||||
second: b,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
conflicts
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct HotkeyConflict {
|
||||
pub binding: KeyBinding,
|
||||
pub first: HotkeyAction,
|
||||
pub second: HotkeyAction,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unset_actions_format_as_unset() {
|
||||
assert_eq!(format_binding(None), "unset");
|
||||
assert_eq!(parse_binding("unset"), None);
|
||||
assert_eq!(parse_binding(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_binding_is_detected() {
|
||||
let mut map = HotkeyMap::default();
|
||||
map.set_binding(HotkeyAction::ToggleMute, parse_binding("M"));
|
||||
map.set_binding(HotkeyAction::ToggleDeafen, parse_binding("m"));
|
||||
let conflicts = map.conflicts();
|
||||
assert_eq!(conflicts.len(), 1);
|
||||
assert_eq!(conflicts[0].first, HotkeyAction::ToggleMute);
|
||||
assert_eq!(conflicts[0].second, HotkeyAction::ToggleDeafen);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_respects_room_tier() {
|
||||
let mut map = HotkeyMap::default();
|
||||
map.set_binding(HotkeyAction::LeaveRoom, parse_binding("Escape"));
|
||||
let binding = parse_binding("Escape").unwrap();
|
||||
assert_eq!(
|
||||
map.lookup_binding(&binding, HotkeyContext { in_call: false }),
|
||||
None,
|
||||
"room-only shortcuts should not fire outside a call"
|
||||
);
|
||||
assert_eq!(
|
||||
map.lookup_binding(&binding, HotkeyContext { in_call: true }),
|
||||
Some(HotkeyAction::LeaveRoom)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_ptt_is_space() {
|
||||
let map = HotkeyMap::default();
|
||||
assert_eq!(
|
||||
format_binding(map.binding(HotkeyAction::PushToTalk)),
|
||||
"Space"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_single_character_case_folds() {
|
||||
assert_eq!(parse_binding("M"), Some(KeyBinding::Character("m".to_string())));
|
||||
assert_eq!(format_binding(parse_binding("m").as_ref()), "M");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user