Some checks failed
ci/woodpecker/pr/check Pipeline was successful
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Build (macos-latest) (pull_request) Has been cancelled
CI / Build (ubuntu-latest) (pull_request) Has been cancelled
CI / Build (windows-latest) (pull_request) Has been cancelled
- Add #[allow(unused_imports)] on pub re-exports used only by lib/tests - Add #[allow(dead_code)] on public API items unused in binary target - Fix collapsible_if, redundant_closure, unnecessary_map_or in main.rs - Prefix unused test variables with underscore Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
489 lines
15 KiB
Rust
489 lines
15 KiB
Rust
/// Модуль для настраиваемых горячих клавиш
|
||
///
|
||
/// Поддерживает:
|
||
/// - Загрузку из конфигурационного файла
|
||
/// - Множественные 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<Command, Vec<KeyBinding>>,
|
||
}
|
||
|
||
impl Keybindings {
|
||
/// Ищет команду по клавише
|
||
pub fn get_command(&self, event: &KeyEvent) -> Option<Command> {
|
||
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<S>(modifiers: &KeyModifiers, serializer: S) -> Result<S::Ok, S::Error>
|
||
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<KeyModifiers, D::Error>
|
||
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<S>(key: &KeyCode, serializer: S) -> Result<S::Ok, S::Error>
|
||
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<KeyCode, D::Error>
|
||
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));
|
||
}
|
||
}
|