Files
peerspeak/src/hotkeys.rs
T
molluskandClaude Opus 4.8 d0a16cb8b9 style: apply cargo fmt across the crate (A20)
The repo never enforced rustfmt, so formatting had drifted broadly. This is a
single mechanical `cargo fmt` pass over the whole crate (no behavioral change;
lib suite green, 493 passed). Going forward fmt should be enforced (planned CI
fmt --check step). Part of the 0.6.1 hygiene pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 02:11:44 -04:00

288 lines
8.6 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");
}
}