Split core and TUI crates
This commit is contained in:
556
crates/tele-tui/src/config/keybindings.rs
Normal file
556
crates/tele-tui/src/config/keybindings.rs
Normal file
@@ -0,0 +1,556 @@
|
||||
/// Модуль для настраиваемых горячих клавиш
|
||||
///
|
||||
/// Поддерживает:
|
||||
/// - Загрузку из конфигурационного файла
|
||||
/// - Множественные 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user