285 lines
8.5 KiB
Rust
285 lines
8.5 KiB
Rust
//! 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");
|
|
}
|
|
}
|