Files
telegram-tui/crates/tele-tui/src/config/keybindings.rs
2026-05-20 00:31:18 +03:00

557 lines
17 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// Модуль для настраиваемых горячих клавиш
///
/// Поддерживает:
/// - Загрузку из конфигурационного файла
/// - Множественные 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,
}
const COMMAND_LOOKUP_ORDER: &[Command] = &[
Command::Quit,
Command::Cancel,
Command::SubmitMessage,
Command::OpenSearch,
Command::OpenSearchInChat,
Command::OpenProfile,
Command::Help,
Command::MoveUp,
Command::MoveDown,
Command::MoveLeft,
Command::MoveRight,
Command::PageUp,
Command::PageDown,
Command::SelectFolder1,
Command::SelectFolder2,
Command::SelectFolder3,
Command::SelectFolder4,
Command::SelectFolder5,
Command::SelectFolder6,
Command::SelectFolder7,
Command::SelectFolder8,
Command::SelectFolder9,
Command::OpenChat,
Command::EditMessage,
Command::DeleteMessage,
Command::ReplyMessage,
Command::ForwardMessage,
Command::CopyMessage,
Command::ReactMessage,
Command::SelectMessage,
Command::ViewImage,
Command::TogglePlayback,
Command::SeekForward,
Command::SeekBackward,
Command::NewLine,
Command::DeleteChar,
Command::DeleteWord,
Command::MoveToStart,
Command::MoveToEnd,
Command::EnterInsertMode,
];
/// Привязка клавиши к команде
#[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 in COMMAND_LOOKUP_ORDER {
if self
.bindings
.get(command)
.is_some_and(|bindings| bindings.iter().any(|binding| binding.matches(event)))
{
return Some(*command);
}
}
None
}
#[cfg(test)]
fn duplicate_bindings(&self) -> Vec<(KeyBinding, Vec<Command>)> {
let mut by_key: HashMap<KeyBinding, Vec<Command>> = HashMap::new();
for command in COMMAND_LOOKUP_ORDER {
if let Some(bindings) = self.bindings.get(command) {
for binding in bindings {
by_key.entry(binding.clone()).or_default().push(*command);
}
}
}
by_key
.into_iter()
.filter(|(_, commands)| commands.len() > 1)
.collect()
}
}
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
],
);
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
],
);
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(' '))]);
// Left/Right are MoveLeft/MoveRight globally; message selection treats them as voice seek.
bindings.insert(Command::SeekForward, vec![]);
bindings.insert(Command::SeekBackward, vec![]);
// 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)]);
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![
// Во многих терминалах Ctrl+I приходит как Tab
KeyBinding::new(KeyCode::Tab),
KeyBinding::with_ctrl(KeyCode::Char('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));
assert_eq!(kb.get_command(&KeyEvent::from(KeyCode::Char('р'))), Some(Command::MoveLeft));
}
#[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));
}
#[test]
fn test_default_bindings_have_no_conflicts() {
let kb = Keybindings::default();
let duplicates = kb.duplicate_bindings();
assert!(duplicates.is_empty(), "duplicate default keybindings: {:?}", duplicates);
}
}