/// Модуль для настраиваемых горячих клавиш /// /// Поддерживает: /// - Загрузку из конфигурационного файла /// - Множественные binding для одной команды (EN/RU раскладки) /// - Type-safe команды через enum use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; /// Команды приложения #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Command { // Navigation MoveUp, MoveDown, MoveLeft, MoveRight, PageUp, PageDown, // Global Quit, OpenSearch, OpenSearchInChat, Help, // Chat list OpenChat, SelectFolder1, SelectFolder2, SelectFolder3, SelectFolder4, SelectFolder5, SelectFolder6, SelectFolder7, SelectFolder8, SelectFolder9, // Message actions EditMessage, DeleteMessage, ReplyMessage, ForwardMessage, CopyMessage, ReactMessage, SelectMessage, // Media ViewImage, // v - просмотр фото // Voice playback TogglePlayback, // Space - play/pause SeekForward, // → - seek +5s SeekBackward, // ← - seek -5s // Input SubmitMessage, Cancel, NewLine, DeleteChar, DeleteWord, MoveToStart, MoveToEnd, // Vim mode EnterInsertMode, // Profile OpenProfile, } /// Привязка клавиши к команде #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct KeyBinding { #[serde(with = "key_code_serde")] pub key: KeyCode, #[serde(with = "key_modifiers_serde")] pub modifiers: KeyModifiers, } impl KeyBinding { pub fn new(key: KeyCode) -> Self { Self { key, modifiers: KeyModifiers::NONE } } pub fn with_ctrl(key: KeyCode) -> Self { Self { key, modifiers: KeyModifiers::CONTROL } } #[allow(dead_code)] pub fn with_shift(key: KeyCode) -> Self { Self { key, modifiers: KeyModifiers::SHIFT } } #[allow(dead_code)] pub fn with_alt(key: KeyCode) -> Self { Self { key, modifiers: KeyModifiers::ALT } } pub fn matches(&self, event: &KeyEvent) -> bool { self.key == event.code && self.modifiers == event.modifiers } } /// Конфигурация горячих клавиш #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Keybindings { #[serde(flatten)] bindings: HashMap>, } impl Keybindings { /// Ищет команду по клавише pub fn get_command(&self, event: &KeyEvent) -> Option { for (command, bindings) in &self.bindings { if bindings.iter().any(|binding| binding.matches(event)) { return Some(*command); } } None } } impl Default for Keybindings { fn default() -> Self { let mut bindings = HashMap::new(); // Navigation bindings.insert( Command::MoveUp, vec![ KeyBinding::new(KeyCode::Up), KeyBinding::new(KeyCode::Char('k')), KeyBinding::new(KeyCode::Char('р')), // RU (custom mapping, not standard ЙЦУКЕН) ], ); bindings.insert( Command::MoveDown, vec![ KeyBinding::new(KeyCode::Down), KeyBinding::new(KeyCode::Char('j')), KeyBinding::new(KeyCode::Char('о')), // RU ], ); bindings.insert( Command::MoveLeft, vec![ KeyBinding::new(KeyCode::Left), KeyBinding::new(KeyCode::Char('h')), KeyBinding::new(KeyCode::Char('л')), // RU (custom mapping, not standard ЙЦУКЕН) ], ); bindings.insert( Command::MoveRight, vec![ KeyBinding::new(KeyCode::Right), KeyBinding::new(KeyCode::Char('l')), KeyBinding::new(KeyCode::Char('д')), // RU ], ); bindings.insert( Command::PageUp, vec![ KeyBinding::new(KeyCode::PageUp), KeyBinding::with_ctrl(KeyCode::Char('u')), ], ); bindings.insert( Command::PageDown, vec![ KeyBinding::new(KeyCode::PageDown), KeyBinding::with_ctrl(KeyCode::Char('d')), ], ); // Global bindings.insert( Command::Quit, vec![ KeyBinding::new(KeyCode::Char('q')), KeyBinding::new(KeyCode::Char('й')), // RU KeyBinding::with_ctrl(KeyCode::Char('c')), ], ); bindings.insert(Command::OpenSearch, vec![KeyBinding::with_ctrl(KeyCode::Char('s'))]); bindings.insert(Command::OpenSearchInChat, vec![KeyBinding::with_ctrl(KeyCode::Char('f'))]); bindings.insert(Command::Help, vec![KeyBinding::new(KeyCode::Char('?'))]); // Chat list // Note: Enter обрабатывается через Command::SubmitMessage в handle_enter_key() for i in 1..=9 { let cmd = match i { 1 => Command::SelectFolder1, 2 => Command::SelectFolder2, 3 => Command::SelectFolder3, 4 => Command::SelectFolder4, 5 => Command::SelectFolder5, 6 => Command::SelectFolder6, 7 => Command::SelectFolder7, 8 => Command::SelectFolder8, 9 => Command::SelectFolder9, _ => unreachable!(), }; bindings.insert( cmd, vec![KeyBinding::new(KeyCode::Char( char::from_digit(i, 10).unwrap(), ))], ); } // Message actions // Note: EditMessage (Up) обрабатывается напрямую в handle_open_chat_keyboard_input // в зависимости от контекста (пустой инпут). Не привязываем здесь, чтобы не // конфликтовать с Command::MoveUp в списке чатов. bindings.insert( Command::DeleteMessage, vec![ KeyBinding::new(KeyCode::Delete), KeyBinding::new(KeyCode::Char('d')), KeyBinding::new(KeyCode::Char('в')), // RU ], ); bindings.insert( Command::ReplyMessage, vec![ KeyBinding::new(KeyCode::Char('r')), KeyBinding::new(KeyCode::Char('к')), // RU ], ); bindings.insert( Command::ForwardMessage, vec![ KeyBinding::new(KeyCode::Char('f')), KeyBinding::new(KeyCode::Char('а')), // RU ], ); bindings.insert( Command::CopyMessage, vec![ KeyBinding::new(KeyCode::Char('y')), KeyBinding::new(KeyCode::Char('н')), // RU ], ); bindings.insert( Command::ReactMessage, vec![ KeyBinding::new(KeyCode::Char('e')), KeyBinding::new(KeyCode::Char('у')), // RU ], ); // Note: SelectMessage обрабатывается через Command::SubmitMessage в handle_enter_key() // Media bindings.insert( Command::ViewImage, vec![ KeyBinding::new(KeyCode::Char('v')), KeyBinding::new(KeyCode::Char('м')), // RU ], ); // Voice playback bindings.insert(Command::TogglePlayback, vec![KeyBinding::new(KeyCode::Char(' '))]); bindings.insert(Command::SeekForward, vec![KeyBinding::new(KeyCode::Right)]); bindings.insert(Command::SeekBackward, vec![KeyBinding::new(KeyCode::Left)]); // Input bindings.insert(Command::SubmitMessage, vec![KeyBinding::new(KeyCode::Enter)]); bindings.insert(Command::Cancel, vec![KeyBinding::new(KeyCode::Esc)]); bindings.insert(Command::NewLine, vec![]); bindings.insert(Command::DeleteChar, vec![KeyBinding::new(KeyCode::Backspace)]); bindings.insert( Command::DeleteWord, vec![ KeyBinding::with_ctrl(KeyCode::Backspace), KeyBinding::with_ctrl(KeyCode::Char('w')), ], ); bindings.insert( Command::MoveToStart, vec![ KeyBinding::new(KeyCode::Home), KeyBinding::with_ctrl(KeyCode::Char('a')), ], ); bindings.insert( Command::MoveToEnd, vec![ KeyBinding::new(KeyCode::End), KeyBinding::with_ctrl(KeyCode::Char('e')), ], ); // Vim mode bindings.insert( Command::EnterInsertMode, vec![ KeyBinding::new(KeyCode::Char('i')), KeyBinding::new(KeyCode::Char('ш')), // RU ], ); // Profile bindings.insert( Command::OpenProfile, vec![ KeyBinding::with_ctrl(KeyCode::Char('u')), // Ctrl+U instead of Ctrl+I KeyBinding::with_ctrl(KeyCode::Char('г')), // RU ], ); Self { bindings } } } /// Сериализация KeyModifiers mod key_modifiers_serde { use crossterm::event::KeyModifiers; use serde::{Deserialize, Deserializer, Serializer}; pub fn serialize(modifiers: &KeyModifiers, serializer: S) -> Result where S: Serializer, { let mut parts = Vec::new(); if modifiers.contains(KeyModifiers::SHIFT) { parts.push("Shift"); } if modifiers.contains(KeyModifiers::CONTROL) { parts.push("Ctrl"); } if modifiers.contains(KeyModifiers::ALT) { parts.push("Alt"); } if modifiers.contains(KeyModifiers::SUPER) { parts.push("Super"); } if modifiers.contains(KeyModifiers::HYPER) { parts.push("Hyper"); } if modifiers.contains(KeyModifiers::META) { parts.push("Meta"); } if parts.is_empty() { serializer.serialize_str("None") } else { serializer.serialize_str(&parts.join("+")) } } pub fn deserialize<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { let s = String::deserialize(deserializer)?; if s == "None" || s.is_empty() { return Ok(KeyModifiers::NONE); } let mut modifiers = KeyModifiers::NONE; for part in s.split('+') { match part.trim() { "Shift" => modifiers |= KeyModifiers::SHIFT, "Ctrl" | "Control" => modifiers |= KeyModifiers::CONTROL, "Alt" => modifiers |= KeyModifiers::ALT, "Super" => modifiers |= KeyModifiers::SUPER, "Hyper" => modifiers |= KeyModifiers::HYPER, "Meta" => modifiers |= KeyModifiers::META, _ => return Err(serde::de::Error::custom(format!("Unknown modifier: {}", part))), } } Ok(modifiers) } } /// Сериализация KeyCode mod key_code_serde { use crossterm::event::KeyCode; use serde::{Deserialize, Deserializer, Serializer}; pub fn serialize(key: &KeyCode, serializer: S) -> Result where S: Serializer, { let s = match key { KeyCode::Char(c) => format!("Char('{}')", c), KeyCode::F(n) => format!("F{}", n), KeyCode::Backspace => "Backspace".to_string(), KeyCode::Enter => "Enter".to_string(), KeyCode::Left => "Left".to_string(), KeyCode::Right => "Right".to_string(), KeyCode::Up => "Up".to_string(), KeyCode::Down => "Down".to_string(), KeyCode::Home => "Home".to_string(), KeyCode::End => "End".to_string(), KeyCode::PageUp => "PageUp".to_string(), KeyCode::PageDown => "PageDown".to_string(), KeyCode::Tab => "Tab".to_string(), KeyCode::BackTab => "BackTab".to_string(), KeyCode::Delete => "Delete".to_string(), KeyCode::Insert => "Insert".to_string(), KeyCode::Esc => "Esc".to_string(), _ => "Unknown".to_string(), }; serializer.serialize_str(&s) } pub fn deserialize<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { let s = String::deserialize(deserializer)?; if s.starts_with("Char('") && s.ends_with("')") { let c = s .chars() .nth(6) .ok_or_else(|| serde::de::Error::custom("Invalid Char format"))?; return Ok(KeyCode::Char(c)); } if let Some(suffix) = s.strip_prefix("F") { let n = suffix.parse().map_err(serde::de::Error::custom)?; return Ok(KeyCode::F(n)); } match s.as_str() { "Backspace" => Ok(KeyCode::Backspace), "Enter" => Ok(KeyCode::Enter), "Left" => Ok(KeyCode::Left), "Right" => Ok(KeyCode::Right), "Up" => Ok(KeyCode::Up), "Down" => Ok(KeyCode::Down), "Home" => Ok(KeyCode::Home), "End" => Ok(KeyCode::End), "PageUp" => Ok(KeyCode::PageUp), "PageDown" => Ok(KeyCode::PageDown), "Tab" => Ok(KeyCode::Tab), "BackTab" => Ok(KeyCode::BackTab), "Delete" => Ok(KeyCode::Delete), "Insert" => Ok(KeyCode::Insert), "Esc" => Ok(KeyCode::Esc), _ => Err(serde::de::Error::custom(format!("Unknown key: {}", s))), } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_default_bindings() { let kb = Keybindings::default(); // Проверяем навигацию assert_eq!(kb.get_command(&KeyEvent::from(KeyCode::Up)), Some(Command::MoveUp)); assert_eq!(kb.get_command(&KeyEvent::from(KeyCode::Char('k'))), Some(Command::MoveUp)); assert_eq!(kb.get_command(&KeyEvent::from(KeyCode::Char('р'))), Some(Command::MoveUp)); } #[test] fn test_get_command() { let kb = Keybindings::default(); let event = KeyEvent::from(KeyCode::Char('q')); assert_eq!(kb.get_command(&event), Some(Command::Quit)); let event = KeyEvent::from(KeyCode::Char('й')); // RU assert_eq!(kb.get_command(&event), Some(Command::Quit)); } #[test] fn test_ctrl_modifier() { let kb = Keybindings::default(); let mut event = KeyEvent::from(KeyCode::Char('s')); event.modifiers = KeyModifiers::CONTROL; assert_eq!(kb.get_command(&event), Some(Command::OpenSearch)); } }