//! 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 { 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 { 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, #[serde(default = "default_deafen")] pub toggle_deafen: Option, #[serde(default = "default_settings")] pub open_settings: Option, #[serde(default = "default_ptt")] pub push_to_talk: Option, #[serde(default)] pub leave_room: Option, } 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 { Some(KeyBinding::Named(name.to_string())) } fn default_mute() -> Option { named("F9") } fn default_deafen() -> Option { named("F10") } fn default_settings() -> Option { named("F2") } fn default_ptt() -> Option { 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) { 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 { 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::ALL .into_iter() .find(|&action| context.allows(action) && self.binding(action) == Some(binding)) } pub fn conflicts(&self) -> Vec { 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"); } }