Split core and TUI crates

This commit is contained in:
Mikhail Kilin
2026-05-20 00:31:18 +03:00
parent 91a8700b8e
commit eefac431e5
238 changed files with 624 additions and 191 deletions

View 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);
}
}

View File

@@ -0,0 +1,197 @@
//! Config file loading, saving, and credentials management.
//!
//! Searches for config at `~/.config/tele-tui/config.toml`.
//! Credentials loaded from file or environment variables.
use std::fs;
use std::path::PathBuf;
use super::Config;
impl Config {
/// Возвращает путь к конфигурационному файлу.
///
/// # Returns
///
/// `Some(PathBuf)` - `~/.config/tele-tui/config.toml`
/// `None` - Не удалось определить директорию конфигурации
pub fn config_path() -> Option<PathBuf> {
dirs::config_dir().map(|mut path| {
path.push("tele-tui");
path.push("config.toml");
path
})
}
/// Путь к директории конфигурации
pub fn config_dir() -> Option<PathBuf> {
dirs::config_dir().map(|mut path| {
path.push("tele-tui");
path
})
}
/// Загружает конфигурацию из файла.
///
/// Ищет конфиг в `~/.config/tele-tui/config.toml`.
/// Если файл не существует, создаёт дефолтный.
/// Если файл невалиден, возвращает дефолтные значения.
///
/// # Returns
///
/// Всегда возвращает валидную конфигурацию.
pub fn load() -> Self {
let config_path = match Self::config_path() {
Some(path) => path,
None => {
tracing::warn!("Could not determine config directory, using defaults");
return Self::default();
}
};
if !config_path.exists() {
// Создаём дефолтный конфиг при первом запуске
let default_config = Self::default();
if let Err(e) = default_config.save() {
tracing::warn!("Could not create default config: {}", e);
}
return default_config;
}
match fs::read_to_string(&config_path) {
Ok(content) => match toml::from_str::<Config>(&content) {
Ok(config) => {
// Валидируем загруженный конфиг
if let Err(e) = config.validate() {
tracing::error!("Config validation error: {}", e);
tracing::warn!("Using default configuration instead");
Self::default()
} else {
config
}
}
Err(e) => {
tracing::warn!("Could not parse config file: {}", e);
Self::default()
}
},
Err(e) => {
tracing::warn!("Could not read config file: {}", e);
Self::default()
}
}
}
/// Сохраняет конфигурацию в файл.
///
/// Создаёт директорию `~/.config/tele-tui/` если её нет.
///
/// # Returns
///
/// * `Ok(())` - Конфиг сохранен
/// * `Err(String)` - Ошибка сохранения
pub fn save(&self) -> Result<(), String> {
let config_dir =
Self::config_dir().ok_or_else(|| "Could not determine config directory".to_string())?;
// Создаём директорию если её нет
fs::create_dir_all(&config_dir)
.map_err(|e| format!("Could not create config directory: {}", e))?;
let config_path = config_dir.join("config.toml");
let toml_string = toml::to_string_pretty(self)
.map_err(|e| format!("Could not serialize config: {}", e))?;
fs::write(&config_path, toml_string)
.map_err(|e| format!("Could not write config file: {}", e))?;
Ok(())
}
/// Путь к файлу credentials
pub fn credentials_path() -> Option<PathBuf> {
Self::config_dir().map(|dir| dir.join("credentials"))
}
/// Загружает API_ID и API_HASH для Telegram.
///
/// Ищет credentials в следующем порядке:
/// 1. `~/.config/tele-tui/credentials` файл
/// 2. Переменные окружения `API_ID` и `API_HASH`
///
/// # Returns
///
/// * `Ok((api_id, api_hash))` - Учетные данные найдены
/// * `Err(String)` - Ошибка с инструкциями по настройке
pub fn load_credentials() -> Result<(i32, String), String> {
// 1. Пробуем загрузить из ~/.config/tele-tui/credentials
if let Some(credentials) = Self::load_credentials_from_file() {
return Ok(credentials);
}
// 2. Пробуем загрузить из переменных окружения (.env)
if let Some(credentials) = Self::load_credentials_from_env() {
return Ok(credentials);
}
// 3. Не нашли credentials - возвращаем инструкции
let credentials_path = Self::credentials_path()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "~/.config/tele-tui/credentials".to_string());
Err(format!(
"Telegram API credentials not found!\n\n\
Please create a file at:\n {}\n\n\
With the following content:\n\
API_ID=your_api_id\n\
API_HASH=your_api_hash\n\n\
You can get API credentials at: https://my.telegram.org/apps\n\n\
Alternatively, you can create a .env file in the current directory.",
credentials_path
))
}
/// Загружает credentials из файла ~/.config/tele-tui/credentials
fn load_credentials_from_file() -> Option<(i32, String)> {
let cred_path = Self::credentials_path()?;
if !cred_path.exists() {
return None;
}
let content = fs::read_to_string(&cred_path).ok()?;
let mut api_id: Option<i32> = None;
let mut api_hash: Option<String> = None;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (key, value) = line.split_once('=')?;
let key = key.trim();
let value = value.trim();
match key {
"API_ID" => api_id = value.parse().ok(),
"API_HASH" => api_hash = Some(value.to_string()),
_ => {}
}
}
Some((api_id?, api_hash?))
}
/// Загружает credentials из переменных окружения (.env)
fn load_credentials_from_env() -> Option<(i32, String)> {
use std::env;
let api_id_str = env::var("API_ID").ok()?;
let api_hash = env::var("API_HASH").ok()?;
let api_id = api_id_str.parse::<i32>().ok()?;
Some((api_id, api_hash))
}
}

View File

@@ -0,0 +1,401 @@
//! Configuration module.
//!
//! Loads settings from `~/.config/tele-tui/config.toml`.
//! Structs: Config, ColorsConfig, NotificationsConfig, Keybindings.
pub mod keybindings;
mod loader;
mod validation;
use serde::{Deserialize, Serialize};
pub use keybindings::{Command, Keybindings};
/// Главная конфигурация приложения.
///
/// Загружается из `~/.config/tele-tui/config.toml` и содержит настройки
/// общего поведения, цветовой схемы и горячих клавиш.
///
/// # Examples
///
/// ```ignore
/// // Загрузка конфигурации
/// let config = Config::load();
///
/// // Доступ к настройкам
/// println!("Incoming color: {}", config.colors.incoming_message);
/// ```
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Config {
/// Цветовая схема интерфейса.
#[serde(default)]
pub colors: ColorsConfig,
/// Горячие клавиши.
#[serde(default)]
pub keybindings: Keybindings,
/// Настройки desktop notifications.
#[serde(default)]
pub notifications: NotificationsConfig,
/// Настройки отображения изображений.
#[serde(default)]
pub images: ImagesConfig,
/// Настройки аудио (голосовые сообщения).
#[serde(default)]
pub audio: AudioConfig,
}
/// Цветовая схема интерфейса.
///
/// Поддерживаемые цвета: red, green, blue, yellow, cyan, magenta,
/// white, black, gray/grey, а также light-варианты (lightred, lightgreen и т.д.).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ColorsConfig {
/// Цвет входящих сообщений (white, gray, cyan и т.д.)
#[serde(default = "default_incoming_color")]
pub incoming_message: String,
/// Цвет исходящих сообщений
#[serde(default = "default_outgoing_color")]
pub outgoing_message: String,
/// Цвет выбранного сообщения
#[serde(default = "default_selected_color")]
pub selected_message: String,
/// Цвет своих реакций
#[serde(default = "default_reaction_chosen_color")]
pub reaction_chosen: String,
/// Цвет чужих реакций
#[serde(default = "default_reaction_other_color")]
pub reaction_other: String,
}
/// Настройки desktop notifications.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationsConfig {
/// Включить/выключить уведомления
#[serde(default = "default_notifications_enabled")]
pub enabled: bool,
/// Уведомлять только при @упоминаниях
#[serde(default)]
pub only_mentions: bool,
/// Показывать превью текста сообщения
#[serde(default = "default_show_preview")]
pub show_preview: bool,
/// Продолжительность показа уведомления (миллисекунды)
/// 0 = системное значение по умолчанию
#[serde(default = "default_notification_timeout")]
pub timeout_ms: i32,
/// Уровень важности: "low", "normal", "critical"
#[serde(default = "default_notification_urgency")]
pub urgency: String,
}
/// Настройки отображения изображений.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImagesConfig {
/// Показывать превью изображений в чате
#[serde(default = "default_show_images")]
pub show_images: bool,
/// Размер кэша изображений (в МБ)
#[serde(default = "default_image_cache_size_mb")]
pub cache_size_mb: u64,
/// Максимальная ширина inline превью (в символах)
#[serde(default = "default_inline_image_max_width")]
pub inline_image_max_width: usize,
/// Автоматически загружать изображения при открытии чата
#[serde(default = "default_auto_download_images")]
pub auto_download_images: bool,
}
impl Default for ImagesConfig {
fn default() -> Self {
Self {
show_images: default_show_images(),
cache_size_mb: default_image_cache_size_mb(),
inline_image_max_width: default_inline_image_max_width(),
auto_download_images: default_auto_download_images(),
}
}
}
/// Настройки аудио (голосовые сообщения).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioConfig {
/// Размер кэша голосовых файлов (в МБ)
#[serde(default = "default_audio_cache_size_mb")]
pub cache_size_mb: u64,
/// Автоматически загружать голосовые при открытии чата
#[serde(default = "default_auto_download_voice")]
pub auto_download_voice: bool,
}
impl Default for AudioConfig {
fn default() -> Self {
Self {
cache_size_mb: default_audio_cache_size_mb(),
auto_download_voice: default_auto_download_voice(),
}
}
}
// Дефолтные значения (используются serde атрибутами)
fn default_incoming_color() -> String {
"white".to_string()
}
fn default_outgoing_color() -> String {
"green".to_string()
}
fn default_selected_color() -> String {
"yellow".to_string()
}
fn default_reaction_chosen_color() -> String {
"yellow".to_string()
}
fn default_reaction_other_color() -> String {
"gray".to_string()
}
fn default_notifications_enabled() -> bool {
false
}
fn default_show_preview() -> bool {
true
}
fn default_notification_timeout() -> i32 {
5000 // 5 seconds
}
fn default_notification_urgency() -> String {
"normal".to_string()
}
fn default_show_images() -> bool {
true
}
fn default_image_cache_size_mb() -> u64 {
crate::constants::DEFAULT_IMAGE_CACHE_SIZE_MB
}
fn default_inline_image_max_width() -> usize {
crate::constants::INLINE_IMAGE_MAX_WIDTH
}
fn default_auto_download_images() -> bool {
true
}
fn default_audio_cache_size_mb() -> u64 {
crate::constants::DEFAULT_AUDIO_CACHE_SIZE_MB
}
fn default_auto_download_voice() -> bool {
false
}
impl Default for ColorsConfig {
fn default() -> Self {
Self {
incoming_message: default_incoming_color(),
outgoing_message: default_outgoing_color(),
selected_message: default_selected_color(),
reaction_chosen: default_reaction_chosen_color(),
reaction_other: default_reaction_other_color(),
}
}
}
impl Default for NotificationsConfig {
fn default() -> Self {
Self {
enabled: default_notifications_enabled(),
only_mentions: false,
show_preview: default_show_preview(),
timeout_ms: default_notification_timeout(),
urgency: default_notification_urgency(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
#[test]
fn test_config_default_includes_keybindings() {
let config = Config::default();
let keybindings = &config.keybindings;
// Test that keybindings exist for common commands
assert!(
keybindings.get_command(&KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE))
== Some(Command::ReplyMessage)
);
assert!(
keybindings.get_command(&KeyEvent::new(KeyCode::Char('к'), KeyModifiers::NONE))
== Some(Command::ReplyMessage)
);
assert!(
keybindings.get_command(&KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE))
== Some(Command::ForwardMessage)
);
assert!(
keybindings.get_command(&KeyEvent::new(KeyCode::Char('а'), KeyModifiers::NONE))
== Some(Command::ForwardMessage)
);
}
#[test]
fn test_config_validate_valid() {
let config = Config::default();
assert!(config.validate().is_ok());
}
#[test]
fn test_config_validate_invalid_color_incoming() {
let mut config = Config::default();
config.colors.incoming_message = "rainbow".to_string();
let result = config.validate();
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid color"));
}
#[test]
fn test_config_validate_invalid_color_outgoing() {
let mut config = Config::default();
config.colors.outgoing_message = "purple".to_string();
let result = config.validate();
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid color"));
}
#[test]
fn test_config_validate_invalid_color_selected() {
let mut config = Config::default();
config.colors.selected_message = "pink".to_string();
let result = config.validate();
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid color"));
}
#[test]
fn test_config_validate_valid_all_standard_colors() {
let colors = [
"black",
"red",
"green",
"yellow",
"blue",
"magenta",
"cyan",
"gray",
"grey",
"white",
"darkgray",
"darkgrey",
"lightred",
"lightgreen",
"lightyellow",
"lightblue",
"lightmagenta",
"lightcyan",
];
for color in colors {
let mut config = Config::default();
config.colors.incoming_message = color.to_string();
config.colors.outgoing_message = color.to_string();
config.colors.selected_message = color.to_string();
config.colors.reaction_chosen = color.to_string();
config.colors.reaction_other = color.to_string();
assert!(config.validate().is_ok(), "Color '{}' should be valid", color);
}
}
#[test]
fn test_config_validate_case_insensitive_colors() {
let mut config = Config::default();
config.colors.incoming_message = "RED".to_string();
config.colors.outgoing_message = "Green".to_string();
config.colors.selected_message = "YELLOW".to_string();
assert!(config.validate().is_ok());
}
#[test]
fn test_parse_color_standard() {
let config = Config::default();
use ratatui::style::Color;
assert_eq!(config.parse_color("red"), Color::Red);
assert_eq!(config.parse_color("green"), Color::Green);
assert_eq!(config.parse_color("blue"), Color::Blue);
}
#[test]
fn test_parse_color_light_variants() {
let config = Config::default();
use ratatui::style::Color;
assert_eq!(config.parse_color("lightred"), Color::LightRed);
assert_eq!(config.parse_color("lightgreen"), Color::LightGreen);
assert_eq!(config.parse_color("lightblue"), Color::LightBlue);
}
#[test]
fn test_parse_color_gray_variants() {
let config = Config::default();
use ratatui::style::Color;
assert_eq!(config.parse_color("gray"), Color::Gray);
assert_eq!(config.parse_color("grey"), Color::Gray);
assert_eq!(config.parse_color("darkgray"), Color::DarkGray);
assert_eq!(config.parse_color("darkgrey"), Color::DarkGray);
}
#[test]
fn test_parse_color_case_insensitive() {
let config = Config::default();
use ratatui::style::Color;
assert_eq!(config.parse_color("RED"), Color::Red);
assert_eq!(config.parse_color("Green"), Color::Green);
assert_eq!(config.parse_color("LIGHTBLUE"), Color::LightBlue);
}
#[test]
fn test_parse_color_invalid_fallback() {
let config = Config::default();
use ratatui::style::Color;
// Invalid colors should fallback to White
assert_eq!(config.parse_color("rainbow"), Color::White);
assert_eq!(config.parse_color("purple"), Color::White);
assert_eq!(config.parse_color("unknown"), Color::White);
}
}

View File

@@ -0,0 +1,80 @@
//! Config validation: color names, notification settings.
use super::Config;
impl Config {
/// Валидация конфигурации
pub fn validate(&self) -> Result<(), String> {
// Проверка цветов
let valid_colors = [
"black",
"red",
"green",
"yellow",
"blue",
"magenta",
"cyan",
"gray",
"grey",
"white",
"darkgray",
"darkgrey",
"lightred",
"lightgreen",
"lightyellow",
"lightblue",
"lightmagenta",
"lightcyan",
];
for color_name in [
&self.colors.incoming_message,
&self.colors.outgoing_message,
&self.colors.selected_message,
&self.colors.reaction_chosen,
&self.colors.reaction_other,
] {
if !valid_colors.contains(&color_name.to_lowercase().as_str()) {
return Err(format!("Invalid color: {}", color_name));
}
}
Ok(())
}
/// Парсит строку цвета в `ratatui::style::Color`.
///
/// Поддерживает стандартные цвета (red, green, blue и т.д.),
/// light-варианты (lightred, lightgreen и т.д.) и grey/gray.
///
/// # Arguments
///
/// * `color_str` - Название цвета (case-insensitive)
///
/// # Returns
///
/// `Color` - Соответствующий цвет или `White` если цвет не распознан
pub fn parse_color(&self, color_str: &str) -> ratatui::style::Color {
use ratatui::style::Color;
match color_str.to_lowercase().as_str() {
"black" => Color::Black,
"red" => Color::Red,
"green" => Color::Green,
"yellow" => Color::Yellow,
"blue" => Color::Blue,
"magenta" => Color::Magenta,
"cyan" => Color::Cyan,
"gray" | "grey" => Color::Gray,
"white" => Color::White,
"darkgray" | "darkgrey" => Color::DarkGray,
"lightred" => Color::LightRed,
"lightgreen" => Color::LightGreen,
"lightyellow" => Color::LightYellow,
"lightblue" => Color::LightBlue,
"lightmagenta" => Color::LightMagenta,
"lightcyan" => Color::LightCyan,
_ => Color::White, // fallback
}
}
}