refactor: complete Phase 13 deep architecture refactoring (etaps 3-7)

Split monolithic files into modular architecture:
- ui/messages.rs (893→365 lines): extract modals/, compose_bar.rs
- tdlib/messages.rs (836→3 files): split into messages/mod, convert, operations
- config/mod.rs (642→3 files): extract validation.rs, loader.rs
- Code duplication cleanup: shared components, ~220 lines removed
- Documentation: PROJECT_STRUCTURE.md rewrite, 16 files got //! docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikhail Kilin
2026-02-06 15:28:11 +03:00
parent 931954d829
commit ffd52d2384
39 changed files with 1706 additions and 1665 deletions

View File

@@ -3,6 +3,7 @@
//! Handles reply, forward, and draft functionality
use crate::app::{App, ChatState};
use crate::app::methods::messages::MessageMethods;
use crate::tdlib::{MessageInfo, TdClientTrait};
/// Compose methods for reply/forward/draft

View File

@@ -3,6 +3,7 @@
//! Handles chat list navigation and selection
use crate::app::{App, ChatState};
use crate::app::methods::search::SearchMethods;
use crate::tdlib::TdClientTrait;
/// Navigation methods for chat list

View File

@@ -1,7 +1,12 @@
//! Application state module.
//!
//! Contains `App<T>` — the central state struct parameterized by `TdClientTrait`
//! for dependency injection. Methods are organized into trait modules in `methods/`.
mod chat_filter;
mod chat_state;
mod state;
mod methods;
pub mod methods;
pub use chat_filter::{ChatFilter, ChatFilterCriteria};
pub use chat_state::ChatState;
@@ -9,7 +14,7 @@ pub use state::AppScreen;
pub use methods::*;
use crate::tdlib::{ChatInfo, TdClient, TdClientTrait};
use crate::types::{ChatId, MessageId};
use crate::types::ChatId;
use ratatui::widgets::ListState;
/// Main application state for the Telegram TUI client.
@@ -35,6 +40,7 @@ use ratatui::widgets::ListState;
///
/// ```no_run
/// use tele_tui::app::App;
/// use tele_tui::app::methods::navigation::NavigationMethods;
/// use tele_tui::config::Config;
///
/// let config = Config::default();

197
src/config/loader.rs Normal file
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

@@ -1,8 +1,13 @@
//! Configuration module.
//!
//! Loads settings from `~/.config/tele-tui/config.toml`.
//! Structs: Config, GeneralConfig, ColorsConfig, NotificationsConfig, Keybindings.
pub mod keybindings;
mod loader;
mod validation;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
pub use keybindings::{Command, Keybindings};
@@ -100,7 +105,7 @@ pub struct NotificationsConfig {
pub urgency: String,
}
// Дефолтные значения
// Дефолтные значения (используются serde атрибутами)
fn default_timezone() -> String {
"+03:00".to_string()
}
@@ -182,298 +187,6 @@ impl Default for Config {
}
}
impl Config {
/// Валидация конфигурации
pub fn validate(&self) -> Result<(), String> {
// Проверка timezone
if !self.general.timezone.starts_with('+') && !self.general.timezone.starts_with('-') {
return Err(format!(
"Invalid timezone (must start with + or -): {}",
self.general.timezone
));
}
// Проверка цветов
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(())
}
/// Возвращает путь к конфигурационному файлу.
///
/// # 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
///
/// Всегда возвращает валидную конфигурацию.
///
/// # Examples
///
/// ```ignore
/// let config = Config::load();
/// ```
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(())
}
/// Парсит строку цвета в `ratatui::style::Color`.
///
/// Поддерживает стандартные цвета (red, green, blue и т.д.),
/// light-варианты (lightred, lightgreen и т.д.) и grey/gray.
///
/// # Arguments
///
/// * `color_str` - Название цвета (case-insensitive)
///
/// # Returns
///
/// `Color` - Соответствующий цвет или `White` если цвет не распознан
///
/// # Examples
///
/// ```ignore
/// let color = config.parse_color("red");
/// let color = config.parse_color("LightBlue");
/// ```
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
}
}
/// Путь к файлу 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)` - Ошибка с инструкциями по настройке
///
/// # Credentials Format
///
/// Файл `~/.config/tele-tui/credentials`:
/// ```text
/// API_ID=12345
/// API_HASH=your_api_hash_here
/// ```
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))
}
}
#[cfg(test)]
mod tests {
use super::*;

88
src/config/validation.rs Normal file
View File

@@ -0,0 +1,88 @@
//! Config validation: timezone format, color names, notification settings.
use super::Config;
impl Config {
/// Валидация конфигурации
pub fn validate(&self) -> Result<(), String> {
// Проверка timezone
if !self.general.timezone.starts_with('+') && !self.general.timezone.starts_with('-') {
return Err(format!(
"Invalid timezone (must start with + or -): {}",
self.general.timezone
));
}
// Проверка цветов
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
}
}
}

View File

@@ -1,4 +1,4 @@
// Application constants
//! Application-wide constants (memory limits, timeouts, UI sizes).
// ============================================================================
// Memory Limits

View File

@@ -7,7 +7,11 @@
//! - Loading older messages
use crate::app::App;
use crate::tdlib::{TdClientTrait, ChatAction, ReplyInfo};
use crate::app::methods::{
compose::ComposeMethods, messages::MessageMethods,
modal::ModalMethods, navigation::NavigationMethods,
};
use crate::tdlib::{TdClientTrait, ChatAction};
use crate::types::{ChatId, MessageId};
use crate::utils::{is_non_empty, with_timeout, with_timeout_msg};
use crate::input::handlers::{copy_to_clipboard, format_message_for_clipboard};

View File

@@ -6,10 +6,11 @@
//! - Opening chats
use crate::app::App;
use crate::app::methods::{compose::ComposeMethods, navigation::NavigationMethods};
use crate::tdlib::TdClientTrait;
use crate::types::{ChatId, MessageId};
use crate::utils::{with_timeout, with_timeout_msg, with_timeout_ignore};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crossterm::event::KeyEvent;
use std::time::Duration;
/// Обработка навигации в списке чатов

View File

@@ -7,10 +7,13 @@
//! - Cursor movement and text editing
use crate::app::App;
use crate::app::methods::{
compose::ComposeMethods, navigation::NavigationMethods, search::SearchMethods,
};
use crate::tdlib::TdClientTrait;
use crate::types::ChatId;
use crate::utils::with_timeout_msg;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crossterm::event::KeyEvent;
use std::time::Duration;
/// Обработка режима выбора чата для пересылки сообщения

View File

@@ -7,6 +7,7 @@
//! - Ctrl+F: Search messages in chat
use crate::app::App;
use crate::app::methods::{modal::ModalMethods, search::SearchMethods};
use crate::tdlib::TdClientTrait;
use crate::types::ChatId;
use crate::utils::{with_timeout, with_timeout_msg};

View File

@@ -22,3 +22,21 @@ pub mod search;
pub use clipboard::*;
pub use global::*;
pub use profile::get_available_actions_count;
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::types::MessageId;
/// Скроллит к сообщению по его ID в текущем чате
pub fn scroll_to_message<T: TdClientTrait>(app: &mut App<T>, message_id: MessageId) {
let msg_index = app
.td_client
.current_chat_messages()
.iter()
.position(|m| m.id() == message_id);
if let Some(idx) = msg_index {
let total = app.td_client.current_chat_messages().len();
app.message_scroll_offset = total.saturating_sub(idx + 5);
}
}

View File

@@ -7,11 +7,13 @@
//! - Profile information modal
use crate::app::App;
use crate::app::methods::{modal::ModalMethods, navigation::NavigationMethods};
use crate::tdlib::TdClientTrait;
use crate::types::{ChatId, MessageId};
use crate::utils::{with_timeout_msg, modal_handler::handle_yes_no};
use crate::input::handlers::get_available_actions_count;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::scroll_to_message;
use crossterm::event::KeyEvent;
use std::time::Duration;
/// Обработка режима профиля пользователя/чата
@@ -295,17 +297,7 @@ pub async fn handle_pinned_mode<T: TdClientTrait>(app: &mut App<T>, _key: KeyEve
}
Some(crate::config::Command::SubmitMessage) => {
if let Some(msg_id) = app.get_selected_pinned_id() {
let msg_id = MessageId::new(msg_id);
let msg_index = app
.td_client
.current_chat_messages()
.iter()
.position(|m| m.id() == msg_id);
if let Some(idx) = msg_index {
let total = app.td_client.current_chat_messages().len();
app.message_scroll_offset = total.saturating_sub(idx + 5);
}
scroll_to_message(app, MessageId::new(msg_id));
app.exit_pinned_mode();
}
}

View File

@@ -6,14 +6,15 @@
//! - Search query input
use crate::app::App;
use crate::app::methods::{navigation::NavigationMethods, search::SearchMethods};
use crate::tdlib::TdClientTrait;
use crate::types::{ChatId, MessageId};
use crate::utils::with_timeout;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crossterm::event::{KeyCode, KeyEvent};
use std::time::Duration;
// Import from chat_list module
use super::chat_list::open_chat_and_load_data;
use super::scroll_to_message;
/// Обработка режима поиска по чатам
///
@@ -75,17 +76,7 @@ pub async fn handle_message_search_mode<T: TdClientTrait>(app: &mut App<T>, key:
}
Some(crate::config::Command::SubmitMessage) => {
if let Some(msg_id) = app.get_selected_search_result_id() {
let msg_id = MessageId::new(msg_id);
let msg_index = app
.td_client
.current_chat_messages()
.iter()
.position(|m| m.id() == msg_id);
if let Some(idx) = msg_index {
let total = app.td_client.current_chat_messages().len();
app.message_scroll_offset = total.saturating_sub(idx + 5);
}
scroll_to_message(app, MessageId::new(msg_id));
app.exit_message_search_mode();
}
}

View File

@@ -1,32 +1,32 @@
//! Main screen input router.
//!
//! Dispatches keyboard events to specialized handlers based on current app mode.
//! Priority order: modals → search → compose → chat → chat list.
use crate::app::App;
use crate::app::methods::{
compose::ComposeMethods,
messages::MessageMethods,
modal::ModalMethods,
navigation::NavigationMethods,
search::SearchMethods,
};
use crate::tdlib::TdClientTrait;
use crate::input::handlers::{
copy_to_clipboard, format_message_for_clipboard, get_available_actions_count,
handle_global_commands,
modal::{
handle_profile_mode, handle_profile_open, handle_delete_confirmation,
handle_reaction_picker_mode, handle_pinned_mode,
},
search::{
handle_chat_search_mode, handle_message_search_mode, perform_message_search,
},
compose::{
handle_forward_mode, forward_selected_message,
},
chat_list::{
handle_chat_list_navigation, select_folder, open_chat_and_load_data,
},
search::{handle_chat_search_mode, handle_message_search_mode},
compose::handle_forward_mode,
chat_list::handle_chat_list_navigation,
chat::{
handle_message_selection, handle_enter_key, send_reaction,
load_older_messages_if_needed, handle_open_chat_keyboard_input,
handle_message_selection, handle_enter_key,
handle_open_chat_keyboard_input,
},
};
use crate::tdlib::ChatAction;
use crate::types::{ChatId, MessageId};
use crate::utils::{is_non_empty, with_timeout, with_timeout_msg, with_timeout_ignore};
use crate::utils::modal_handler::handle_yes_no;
use crossterm::event::{KeyCode, KeyEvent};
use std::time::{Duration, Instant};
use crossterm::event::KeyEvent;

View File

@@ -1,3 +1,7 @@
//! Input handling module.
//!
//! Routes keyboard events by screen (Auth vs Main) to specialized handlers.
mod auth;
pub mod handlers;
mod main_input;

View File

@@ -1,5 +1,6 @@
// Library interface for tele-tui
// This allows tests to import modules
//! tele-tui — TUI client for Telegram
//!
//! Library interface exposing modules for integration testing.
pub mod app;
pub mod config;

View File

@@ -269,7 +269,7 @@ mod tests {
#[test]
fn test_notification_manager_creation() {
let manager = NotificationManager::new();
assert!(manager.enabled);
assert!(!manager.enabled); // disabled by default
assert!(!manager.only_mentions);
assert!(manager.show_preview);
}

View File

@@ -0,0 +1,136 @@
//! TDLib message conversion: JSON → MessageInfo, reply info fetching.
use crate::types::{ChatId, MessageId};
use tdlib_rs::functions;
use tdlib_rs::types::Message as TdMessage;
use crate::tdlib::types::{MessageBuilder, MessageInfo};
use super::MessageManager;
impl MessageManager {
/// Конвертировать TdMessage в MessageInfo
pub(crate) async fn convert_message(&self, msg: &TdMessage) -> Option<MessageInfo> {
use crate::tdlib::message_conversion::{
extract_content_text, extract_entities, extract_forward_info,
extract_reactions, extract_reply_info, extract_sender_name,
};
// Извлекаем все части сообщения используя вспомогательные функции
let content_text = extract_content_text(msg);
let entities = extract_entities(msg);
let sender_name = extract_sender_name(msg, self.client_id).await;
let forward_from = extract_forward_info(msg);
let reply_to = extract_reply_info(msg);
let reactions = extract_reactions(msg);
let mut builder = MessageBuilder::new(MessageId::new(msg.id))
.sender_name(sender_name)
.text(content_text)
.entities(entities)
.date(msg.date)
.edit_date(msg.edit_date);
if msg.is_outgoing {
builder = builder.outgoing();
} else {
builder = builder.incoming();
}
if !msg.contains_unread_mention {
builder = builder.read();
} else {
builder = builder.unread();
}
if msg.can_be_edited {
builder = builder.editable();
}
if msg.can_be_deleted_only_for_self {
builder = builder.deletable_for_self();
}
if msg.can_be_deleted_for_all_users {
builder = builder.deletable_for_all();
}
if let Some(reply) = reply_to {
builder = builder.reply_to(reply);
}
if let Some(forward) = forward_from {
builder = builder.forward_from(forward);
}
builder = builder.reactions(reactions);
Some(builder.build())
}
/// Загружает недостающую информацию об исходных сообщениях для ответов.
///
/// Ищет все reply-сообщения с `sender_name == "Unknown"` и загружает
/// полную информацию (имя отправителя, текст) из TDLib.
///
/// # Note
///
/// Вызывайте после загрузки истории чата для заполнения информации о цитируемых сообщениях.
pub async fn fetch_missing_reply_info(&mut self) {
// Early return if no chat selected
let Some(chat_id) = self.current_chat_id else {
return;
};
// Collect message IDs with missing reply info using filter_map
let to_fetch: Vec<MessageId> = self
.current_chat_messages
.iter()
.filter_map(|msg| {
msg.interactions
.reply_to
.as_ref()
.filter(|reply| reply.sender_name == "Unknown")
.map(|reply| reply.message_id)
})
.collect();
// Fetch and update each missing message
for message_id in to_fetch {
self.fetch_and_update_reply(chat_id, message_id).await;
}
}
/// Загружает одно сообщение и обновляет reply информацию.
async fn fetch_and_update_reply(&mut self, chat_id: ChatId, message_id: MessageId) {
// Try to fetch the original message
let Ok(original_msg_enum) =
functions::get_message(chat_id.as_i64(), message_id.as_i64(), self.client_id).await
else {
return;
};
let tdlib_rs::enums::Message::Message(original_msg) = original_msg_enum;
let Some(orig_info) = self.convert_message(&original_msg).await else {
return;
};
// Extract text preview (first 50 chars)
let text_preview: String = orig_info
.content
.text
.chars()
.take(50)
.collect();
// Update reply info in all messages that reference this message
self.current_chat_messages
.iter_mut()
.filter_map(|msg| msg.interactions.reply_to.as_mut())
.filter(|reply| reply.message_id == message_id)
.for_each(|reply| {
reply.sender_name = orig_info.metadata.sender_name.clone();
reply.text = text_preview.clone();
});
}
}

101
src/tdlib/messages/mod.rs Normal file
View File

@@ -0,0 +1,101 @@
//! Message management: storage, conversion, and TDLib API operations.
mod convert;
mod operations;
use crate::constants::MAX_MESSAGES_IN_CHAT;
use crate::types::{ChatId, MessageId};
use super::types::MessageInfo;
/// Менеджер сообщений TDLib.
///
/// Управляет загрузкой, отправкой, редактированием и удалением сообщений.
/// Кеширует сообщения текущего открытого чата и закрепленные сообщения.
///
/// # Основные возможности
///
/// - Загрузка истории сообщений чата
/// - Отправка текстовых сообщений с поддержкой Markdown
/// - Редактирование и удаление сообщений
/// - Пересылка сообщений между чатами
/// - Поиск сообщений по тексту
/// - Управление закрепленными сообщениями
/// - Управление черновиками
/// - Автоматическая отметка сообщений как прочитанных
///
/// # Examples
///
/// ```ignore
/// let mut msg_manager = MessageManager::new(client_id);
///
/// // Загрузить историю чата
/// let messages = msg_manager.get_chat_history(chat_id, 50).await?;
///
/// // Отправить сообщение
/// let msg = msg_manager.send_message(
/// chat_id,
/// "Hello, **world**!".to_string(),
/// None,
/// None
/// ).await?;
/// ```
pub struct MessageManager {
/// Список сообщений текущего открытого чата (до MAX_MESSAGES_IN_CHAT).
pub current_chat_messages: Vec<MessageInfo>,
/// ID текущего открытого чата.
pub current_chat_id: Option<ChatId>,
/// Текущее закрепленное сообщение открытого чата.
pub current_pinned_message: Option<MessageInfo>,
/// Очередь сообщений для отметки как прочитанных: (chat_id, message_ids).
pub pending_view_messages: Vec<(ChatId, Vec<MessageId>)>,
/// ID клиента TDLib для API вызовов.
pub(crate) client_id: i32,
}
impl MessageManager {
/// Создает новый менеджер сообщений.
///
/// # Arguments
///
/// * `client_id` - ID клиента TDLib для API вызовов
///
/// # Returns
///
/// Новый экземпляр `MessageManager` с пустым списком сообщений.
pub fn new(client_id: i32) -> Self {
Self {
current_chat_messages: Vec::new(),
current_chat_id: None,
current_pinned_message: None,
pending_view_messages: Vec::new(),
client_id,
}
}
/// Добавляет сообщение в список текущего чата.
///
/// Автоматически ограничивает размер списка до [`MAX_MESSAGES_IN_CHAT`],
/// удаляя старые сообщения при превышении лимита.
///
/// # Arguments
///
/// * `msg` - Сообщение для добавления
///
/// # Note
///
/// Сообщение добавляется в конец списка. При превышении лимита
/// удаляются самые старые сообщения из начала списка.
pub fn push_message(&mut self, msg: MessageInfo) {
self.current_chat_messages.push(msg); // Добавляем в конец
// Ограничиваем размер списка (удаляем старые с начала)
if self.current_chat_messages.len() > MAX_MESSAGES_IN_CHAT {
self.current_chat_messages.drain(0..(self.current_chat_messages.len() - MAX_MESSAGES_IN_CHAT));
}
}
}

View File

@@ -1,103 +1,17 @@
use crate::constants::{MAX_MESSAGES_IN_CHAT, TDLIB_MESSAGE_LIMIT};
//! TDLib message API operations: history, send, edit, delete, forward, search.
use crate::constants::TDLIB_MESSAGE_LIMIT;
use crate::types::{ChatId, MessageId};
use tdlib_rs::enums::{InputMessageContent, InputMessageReplyTo, SearchMessagesFilter, TextParseMode};
use tdlib_rs::functions;
use tdlib_rs::types::{FormattedText, InputMessageReplyToMessage, InputMessageText, Message as TdMessage, TextParseModeMarkdown};
use tdlib_rs::types::{FormattedText, InputMessageReplyToMessage, InputMessageText, TextParseModeMarkdown};
use tokio::time::{sleep, Duration};
use super::types::{MessageBuilder, MessageInfo, ReplyInfo};
use crate::tdlib::types::{MessageInfo, ReplyInfo};
/// Менеджер сообщений TDLib.
///
/// Управляет загрузкой, отправкой, редактированием и удалением сообщений.
/// Кеширует сообщения текущего открытого чата и закрепленные сообщения.
///
/// # Основные возможности
///
/// - Загрузка истории сообщений чата
/// - Отправка текстовых сообщений с поддержкой Markdown
/// - Редактирование и удаление сообщений
/// - Пересылка сообщений между чатами
/// - Поиск сообщений по тексту
/// - Управление закрепленными сообщениями
/// - Управление черновиками
/// - Автоматическая отметка сообщений как прочитанных
///
/// # Examples
///
/// ```ignore
/// let mut msg_manager = MessageManager::new(client_id);
///
/// // Загрузить историю чата
/// let messages = msg_manager.get_chat_history(chat_id, 50).await?;
///
/// // Отправить сообщение
/// let msg = msg_manager.send_message(
/// chat_id,
/// "Hello, **world**!".to_string(),
/// None,
/// None
/// ).await?;
/// ```
pub struct MessageManager {
/// Список сообщений текущего открытого чата (до MAX_MESSAGES_IN_CHAT).
pub current_chat_messages: Vec<MessageInfo>,
/// ID текущего открытого чата.
pub current_chat_id: Option<ChatId>,
/// Текущее закрепленное сообщение открытого чата.
pub current_pinned_message: Option<MessageInfo>,
/// Очередь сообщений для отметки как прочитанных: (chat_id, message_ids).
pub pending_view_messages: Vec<(ChatId, Vec<MessageId>)>,
/// ID клиента TDLib для API вызовов.
client_id: i32,
}
use super::MessageManager;
impl MessageManager {
/// Создает новый менеджер сообщений.
///
/// # Arguments
///
/// * `client_id` - ID клиента TDLib для API вызовов
///
/// # Returns
///
/// Новый экземпляр `MessageManager` с пустым списком сообщений.
pub fn new(client_id: i32) -> Self {
Self {
current_chat_messages: Vec::new(),
current_chat_id: None,
current_pinned_message: None,
pending_view_messages: Vec::new(),
client_id,
}
}
/// Добавляет сообщение в список текущего чата.
///
/// Автоматически ограничивает размер списка до [`MAX_MESSAGES_IN_CHAT`],
/// удаляя старые сообщения при превышении лимита.
///
/// # Arguments
///
/// * `msg` - Сообщение для добавления
///
/// # Note
///
/// Сообщение добавляется в конец списка. При превышении лимита
/// удаляются самые старые сообщения из начала списка.
pub fn push_message(&mut self, msg: MessageInfo) {
self.current_chat_messages.push(msg); // Добавляем в конец
// Ограничиваем размер списка (удаляем старые с начала)
if self.current_chat_messages.len() > MAX_MESSAGES_IN_CHAT {
self.current_chat_messages.drain(0..(self.current_chat_messages.len() - MAX_MESSAGES_IN_CHAT));
}
}
/// Загружает историю сообщений чата с динамической подгрузкой.
///
/// Загружает сообщения чанками, ожидая пока TDLib синхронизирует их с сервера.
@@ -172,7 +86,7 @@ impl MessageManager {
};
let received_count = messages_obj.messages.len();
// Если получили пустой результат
if messages_obj.messages.is_empty() {
consecutive_empty_results += 1;
@@ -183,10 +97,10 @@ impl MessageManager {
// Пробуем еще раз
continue;
}
// Получили сообщения - сбрасываем счетчик
consecutive_empty_results = 0;
// Если это первая загрузка и получили мало сообщений - продолжаем попытки
// TDLib может подгружать данные с сервера постепенно
if all_messages.is_empty() &&
@@ -212,7 +126,7 @@ impl MessageManager {
if !chunk_messages.is_empty() {
// Для следующей итерации: ID самого старого сообщения из текущего чанка
from_message_id = chunk_messages[0].id().as_i64();
// ВАЖНО: Вставляем чанк В НАЧАЛО списка!
// Первый чанк содержит НОВЫЕ сообщения (например 51-100)
// Второй чанк содержит СТАРЫЕ сообщения (например 1-50)
@@ -224,7 +138,7 @@ impl MessageManager {
// Последующие чанки - вставляем в начало
all_messages.splice(0..0, chunk_messages);
}
chunk_loaded = true;
}
@@ -241,7 +155,7 @@ impl MessageManager {
break;
}
}
Ok(all_messages)
}
@@ -364,13 +278,6 @@ impl MessageManager {
// Нужно использовать getChatPinnedMessage или альтернативный способ.
// Временно отключено.
self.current_pinned_message = None;
// match functions::get_chat(chat_id, self.client_id).await {
// Ok(tdlib_rs::enums::Chat::Chat(chat)) => {
// // chat.pinned_message_id больше не существует
// }
// _ => {}
// }
}
/// Выполняет поиск сообщений по тексту в указанном чате.
@@ -515,7 +422,7 @@ impl MessageManager {
.convert_message(&msg)
.await
.ok_or_else(|| "Не удалось конвертировать сообщение".to_string())?;
// Добавляем reply_info если был передан
if let Some(reply) = reply_info {
msg_info.interactions.reply_to = Some(reply);
@@ -708,129 +615,4 @@ impl MessageManager {
let _ = functions::view_messages(chat_id.as_i64(), ids, None, true, self.client_id).await;
}
}
/// Конвертировать TdMessage в MessageInfo
async fn convert_message(&self, msg: &TdMessage) -> Option<MessageInfo> {
use crate::tdlib::message_conversion::{
extract_content_text, extract_entities, extract_forward_info,
extract_reactions, extract_reply_info, extract_sender_name,
};
// Извлекаем все части сообщения используя вспомогательные функции
let content_text = extract_content_text(msg);
let entities = extract_entities(msg);
let sender_name = extract_sender_name(msg, self.client_id).await;
let forward_from = extract_forward_info(msg);
let reply_to = extract_reply_info(msg);
let reactions = extract_reactions(msg);
let mut builder = MessageBuilder::new(MessageId::new(msg.id))
.sender_name(sender_name)
.text(content_text)
.entities(entities)
.date(msg.date)
.edit_date(msg.edit_date);
if msg.is_outgoing {
builder = builder.outgoing();
} else {
builder = builder.incoming();
}
if !msg.contains_unread_mention {
builder = builder.read();
} else {
builder = builder.unread();
}
if msg.can_be_edited {
builder = builder.editable();
}
if msg.can_be_deleted_only_for_self {
builder = builder.deletable_for_self();
}
if msg.can_be_deleted_for_all_users {
builder = builder.deletable_for_all();
}
if let Some(reply) = reply_to {
builder = builder.reply_to(reply);
}
if let Some(forward) = forward_from {
builder = builder.forward_from(forward);
}
builder = builder.reactions(reactions);
Some(builder.build())
}
/// Загружает недостающую информацию об исходных сообщениях для ответов.
///
/// Ищет все reply-сообщения с `sender_name == "Unknown"` и загружает
/// полную информацию (имя отправителя, текст) из TDLib.
///
/// # Note
///
/// Вызывайте после загрузки истории чата для заполнения информации о цитируемых сообщениях.
pub async fn fetch_missing_reply_info(&mut self) {
// Early return if no chat selected
let Some(chat_id) = self.current_chat_id else {
return;
};
// Collect message IDs with missing reply info using filter_map
let to_fetch: Vec<MessageId> = self
.current_chat_messages
.iter()
.filter_map(|msg| {
msg.interactions
.reply_to
.as_ref()
.filter(|reply| reply.sender_name == "Unknown")
.map(|reply| reply.message_id)
})
.collect();
// Fetch and update each missing message
for message_id in to_fetch {
self.fetch_and_update_reply(chat_id, message_id).await;
}
}
/// Загружает одно сообщение и обновляет reply информацию.
async fn fetch_and_update_reply(&mut self, chat_id: ChatId, message_id: MessageId) {
// Try to fetch the original message
let Ok(original_msg_enum) =
functions::get_message(chat_id.as_i64(), message_id.as_i64(), self.client_id).await
else {
return;
};
let tdlib_rs::enums::Message::Message(original_msg) = original_msg_enum;
let Some(orig_info) = self.convert_message(&original_msg).await else {
return;
};
// Extract text preview (first 50 chars)
let text_preview: String = orig_info
.content
.text
.chars()
.take(50)
.collect();
// Update reply info in all messages that reference this message
self.current_chat_messages
.iter_mut()
.filter_map(|msg| msg.interactions.reply_to.as_mut())
.filter(|reply| reply.message_id == message_id)
.for_each(|reply| {
reply.sender_name = orig_info.metadata.sender_name.clone();
reply.text = text_preview.clone();
});
}
}

View File

@@ -1,4 +1,6 @@
/// Type-safe ID wrappers to prevent mixing up different ID types
//! Type-safe ID wrappers to prevent mixing up different ID types.
//!
//! Provides `ChatId` and `MessageId` newtypes for compile-time safety.
use serde::{Deserialize, Serialize};
use std::fmt;

View File

@@ -1,4 +1,7 @@
//! Chat list panel: search box, chat items, and user online status.
use crate::app::App;
use crate::app::methods::{compose::ComposeMethods, search::SearchMethods};
use crate::tdlib::TdClientTrait;
use crate::tdlib::UserOnlineStatus;
use crate::ui::components;
@@ -68,55 +71,16 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
f.render_stateful_widget(chats_list, chat_chunks[1], &mut app.chat_list_state);
// User status - показываем статус выбранного чата
let (status_text, status_color) = if let Some(chat_id) = app.selected_chat_id {
match app.td_client.get_user_status_by_chat_id(chat_id) {
Some(UserOnlineStatus::Online) => ("● онлайн".to_string(), Color::Green),
Some(UserOnlineStatus::Recently) => ("был(а) недавно".to_string(), Color::Yellow),
Some(UserOnlineStatus::Offline(was_online)) => {
let formatted = format_was_online(*was_online);
(formatted, Color::Gray)
}
Some(UserOnlineStatus::LastWeek) => {
("был(а) на этой неделе".to_string(), Color::DarkGray)
}
Some(UserOnlineStatus::LastMonth) => {
("был(а) в этом месяце".to_string(), Color::DarkGray)
}
Some(UserOnlineStatus::LongTimeAgo) => ("был(а) давно".to_string(), Color::DarkGray),
None => ("".to_string(), Color::DarkGray), // Для групп/каналов
}
// User status - показываем статус выбранного или выделенного чата
let status_chat_id = if app.selected_chat_id.is_some() {
app.selected_chat_id
} else {
// Показываем статус выделенного в списке чата
let filtered = app.get_filtered_chats();
if let Some(i) = app.chat_list_state.selected() {
if let Some(chat) = filtered.get(i) {
match app.td_client.get_user_status_by_chat_id(chat.id) {
Some(UserOnlineStatus::Online) => ("● онлайн".to_string(), Color::Green),
Some(UserOnlineStatus::Recently) => {
("был(а) недавно".to_string(), Color::Yellow)
}
Some(UserOnlineStatus::Offline(was_online)) => {
let formatted = format_was_online(*was_online);
(formatted, Color::Gray)
}
Some(UserOnlineStatus::LastWeek) => {
("был(а) на этой неделе".to_string(), Color::DarkGray)
}
Some(UserOnlineStatus::LastMonth) => {
("был(а) в этом месяце".to_string(), Color::DarkGray)
}
Some(UserOnlineStatus::LongTimeAgo) => {
("был(а) давно".to_string(), Color::DarkGray)
}
None => ("".to_string(), Color::DarkGray),
}
} else {
("".to_string(), Color::DarkGray)
}
} else {
("".to_string(), Color::DarkGray)
}
app.chat_list_state.selected().and_then(|i| filtered.get(i).map(|c| c.id))
};
let (status_text, status_color) = match status_chat_id {
Some(chat_id) => format_user_status(app.td_client.get_user_status_by_chat_id(chat_id)),
None => ("".to_string(), Color::DarkGray),
};
let status = Paragraph::new(status_text)
@@ -125,7 +89,17 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
f.render_widget(status, chat_chunks[2]);
}
/// Форматирование времени "был(а) в ..."
fn format_was_online(timestamp: i32) -> String {
crate::utils::format_was_online(timestamp)
/// Форматирует статус пользователя для отображения в статус-баре
fn format_user_status(status: Option<&UserOnlineStatus>) -> (String, Color) {
match status {
Some(UserOnlineStatus::Online) => ("● онлайн".to_string(), Color::Green),
Some(UserOnlineStatus::Recently) => ("был(а) недавно".to_string(), Color::Yellow),
Some(UserOnlineStatus::Offline(was_online)) => {
(crate::utils::format_was_online(*was_online), Color::Gray)
}
Some(UserOnlineStatus::LastWeek) => ("был(а) на этой неделе".to_string(), Color::DarkGray),
Some(UserOnlineStatus::LastMonth) => ("был(а) в этом месяце".to_string(), Color::DarkGray),
Some(UserOnlineStatus::LongTimeAgo) => ("был(а) давно".to_string(), Color::DarkGray),
None => ("".to_string(), Color::DarkGray),
}
}

View File

@@ -0,0 +1,116 @@
//! Shared message list rendering for search and pinned modals
use crate::tdlib::MessageInfo;
use ratatui::{
layout::Alignment,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
/// Renders a single message item with marker, sender, date, and wrapped text
pub fn render_message_item(
msg: &MessageInfo,
is_selected: bool,
content_width: usize,
max_preview_lines: usize,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
// Marker, sender name, and date
let marker = if is_selected { "" } else { " " };
let sender_color = if msg.is_outgoing() {
Color::Green
} else {
Color::Cyan
};
let sender_name = if msg.is_outgoing() {
"Вы".to_string()
} else {
msg.sender_name().to_string()
};
lines.push(Line::from(vec![
Span::styled(
marker.to_string(),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{} ", sender_name),
Style::default()
.fg(sender_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("({})", crate::utils::format_datetime(msg.date())),
Style::default().fg(Color::Gray),
),
]));
// Wrapped message text
let msg_color = if is_selected {
Color::Yellow
} else {
Color::White
};
let max_width = content_width.saturating_sub(4);
let wrapped = crate::ui::messages::wrap_text_with_offsets(msg.text(), max_width);
let wrapped_count = wrapped.len();
for wrapped_line in wrapped.into_iter().take(max_preview_lines) {
lines.push(Line::from(vec![
Span::raw(" ".to_string()),
Span::styled(wrapped_line.text, Style::default().fg(msg_color)),
]));
}
if wrapped_count > max_preview_lines {
lines.push(Line::from(vec![
Span::raw(" ".to_string()),
Span::styled("...".to_string(), Style::default().fg(Color::Gray)),
]));
}
lines
}
/// Calculates scroll offset to keep selected item visible
pub fn calculate_scroll_offset(
selected_index: usize,
lines_per_item: usize,
visible_height: u16,
) -> u16 {
let visible = visible_height.saturating_sub(2) as usize;
let selected_line = selected_index * lines_per_item;
if selected_line > visible / 2 {
(selected_line - visible / 2) as u16
} else {
0
}
}
/// Renders a help bar with keyboard shortcuts
pub fn render_help_bar(shortcuts: &[(&str, &str, Color)], border_color: Color) -> Paragraph<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
for (i, (key, label, color)) in shortcuts.iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" ".to_string()));
}
spans.push(Span::styled(
format!(" {} ", key),
Style::default()
.fg(*color)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(label.to_string()));
}
Paragraph::new(Line::from(spans))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color)),
)
.alignment(Alignment::Center)
}

View File

@@ -1,8 +1,9 @@
// UI компоненты для переиспользования
//! Reusable UI components: message bubbles, input fields, modals, lists.
pub mod modal;
pub mod input_field;
pub mod message_bubble;
pub mod message_list;
pub mod chat_list_item;
pub mod emoji_picker;
@@ -11,3 +12,4 @@ pub use input_field::render_input_field;
pub use chat_list_item::render_chat_list_item;
pub use emoji_picker::render_emoji_picker;
pub use message_bubble::{render_date_separator, render_message_bubble, render_sender_header};
pub use message_list::{render_message_item, calculate_scroll_offset, render_help_bar};

170
src/ui/compose_bar.rs Normal file
View File

@@ -0,0 +1,170 @@
//! Compose bar / input box rendering
use crate::app::App;
use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods};
use crate::tdlib::TdClientTrait;
use crate::ui::components;
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
/// Renders input field with cursor at the specified position
fn render_input_with_cursor(
prefix: &str,
text: &str,
cursor_pos: usize,
color: Color,
) -> Line<'static> {
// Используем компонент input_field
components::render_input_field(prefix, text, cursor_pos, color)
}
/// Renders input box with support for different modes (forward/select/edit/reply/normal)
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
let (input_line, input_title) = if app.is_forwarding() {
// Режим пересылки - показываем превью сообщения
let forward_preview = app
.get_forwarding_message()
.map(|m| {
let text_preview: String = m.text().chars().take(40).collect();
let ellipsis = if m.text().chars().count() > 40 {
"..."
} else {
""
};
format!("{}{}", text_preview, ellipsis)
})
.unwrap_or_else(|| "↪ ...".to_string());
let line = Line::from(Span::styled(forward_preview, Style::default().fg(Color::Cyan)));
(line, " Выберите чат ← ")
} else if app.is_selecting_message() {
// Режим выбора сообщения - подсказка зависит от возможностей
let selected_msg = app.get_selected_message();
let can_edit = selected_msg
.as_ref()
.map(|m| m.can_be_edited() && m.is_outgoing())
.unwrap_or(false);
let can_delete = selected_msg
.as_ref()
.map(|m| m.can_be_deleted_only_for_self() || m.can_be_deleted_for_all_users())
.unwrap_or(false);
let hint = match (can_edit, can_delete) {
(true, true) => "↑↓ · Enter ред. · r ответ · f перслть · y копир. · d удал. · Esc",
(true, false) => "↑↓ · Enter ред. · r ответ · f переслть · y копир. · Esc",
(false, true) => "↑↓ · r ответ · f переслать · y копир. · d удалить · Esc",
(false, false) => "↑↓ · r ответить · f переслать · y копировать · Esc",
};
(
Line::from(Span::styled(hint, Style::default().fg(Color::Cyan))),
" Выбор сообщения ",
)
} else if app.is_editing() {
// Режим редактирования
if app.message_input.is_empty() {
// Пустой инпут - показываем курсор и placeholder
let line = Line::from(vec![
Span::raw(""),
Span::styled("", Style::default().fg(Color::Magenta)),
Span::styled(" Введите новый текст...", Style::default().fg(Color::Gray)),
]);
(line, " Редактирование (Esc отмена) ")
} else {
// Текст с курсором
let line = render_input_with_cursor(
"",
&app.message_input,
app.cursor_position,
Color::Magenta,
);
(line, " Редактирование (Esc отмена) ")
}
} else if app.is_replying() {
// Режим ответа на сообщение
let reply_preview = app
.get_replying_to_message()
.map(|m| {
let sender = if m.is_outgoing() {
"Вы"
} else {
m.sender_name()
};
let text_preview: String = m.text().chars().take(30).collect();
let ellipsis = if m.text().chars().count() > 30 {
"..."
} else {
""
};
format!("{}: {}{}", sender, text_preview, ellipsis)
})
.unwrap_or_else(|| "...".to_string());
if app.message_input.is_empty() {
let line = Line::from(vec![
Span::styled("", Style::default().fg(Color::Cyan)),
Span::styled(reply_preview, Style::default().fg(Color::Gray)),
Span::raw(" "),
Span::styled("", Style::default().fg(Color::Yellow)),
]);
(line, " Ответ (Esc отмена) ")
} else {
let short_preview: String = reply_preview.chars().take(15).collect();
let prefix = format!("{} > ", short_preview);
let line = render_input_with_cursor(
&prefix,
&app.message_input,
app.cursor_position,
Color::Yellow,
);
(line, " Ответ (Esc отмена) ")
}
} else {
// Обычный режим
if app.message_input.is_empty() {
// Пустой инпут - показываем курсор и placeholder
let line = Line::from(vec![
Span::raw("> "),
Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(" Введите сообщение...", Style::default().fg(Color::Gray)),
]);
(line, "")
} else {
// Текст с курсором
let line = render_input_with_cursor(
"> ",
&app.message_input,
app.cursor_position,
Color::Yellow,
);
(line, "")
}
};
let input_block = if input_title.is_empty() {
Block::default().borders(Borders::ALL)
} else {
let title_color = if app.is_replying() || app.is_forwarding() {
Color::Cyan
} else {
Color::Magenta
};
Block::default()
.borders(Borders::ALL)
.title(input_title)
.title_style(
Style::default()
.fg(title_color)
.add_modifier(Modifier::BOLD),
)
};
let input = Paragraph::new(input_line)
.block(input_block)
.wrap(ratatui::widgets::Wrap { trim: false });
f.render_widget(input, area);
}

View File

@@ -1,7 +1,14 @@
//! Chat message area rendering.
//!
//! Renders message bubbles grouped by date/sender, pinned bar, and delegates
//! to modals (search, pinned, reactions, delete) and compose_bar.
use crate::app::App;
use crate::app::methods::{messages::MessageMethods, modal::ModalMethods, search::SearchMethods};
use crate::tdlib::TdClientTrait;
use crate::message_grouping::{group_messages, MessageGroup};
use crate::ui::components;
use crate::ui::{compose_bar, modals};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
@@ -88,24 +95,14 @@ fn render_pinned_bar<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>)
f.render_widget(pinned_bar, area);
}
fn render_input_with_cursor(
prefix: &str,
text: &str,
cursor_pos: usize,
color: Color,
) -> Line<'static> {
// Используем компонент input_field
components::render_input_field(prefix, text, cursor_pos, color)
}
/// Информация о строке после переноса: текст и позиция в оригинале
struct WrappedLine {
text: String,
pub(super) struct WrappedLine {
pub text: String,
}
/// Разбивает текст на строки с учётом максимальной ширины
/// (используется только для search/pinned режимов, основной рендеринг через message_bubble)
fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
pub(super) fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
if max_width == 0 {
return vec![WrappedLine {
text: text.to_string(),
@@ -277,153 +274,6 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>
f.render_widget(messages_widget, area);
}
/// Рендерит input box с поддержкой разных режимов (forward/select/edit/reply/normal)
fn render_input_box<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
let (input_line, input_title) = if app.is_forwarding() {
// Режим пересылки - показываем превью сообщения
let forward_preview = app
.get_forwarding_message()
.map(|m| {
let text_preview: String = m.text().chars().take(40).collect();
let ellipsis = if m.text().chars().count() > 40 {
"..."
} else {
""
};
format!("{}{}", text_preview, ellipsis)
})
.unwrap_or_else(|| "↪ ...".to_string());
let line = Line::from(Span::styled(forward_preview, Style::default().fg(Color::Cyan)));
(line, " Выберите чат ← ")
} else if app.is_selecting_message() {
// Режим выбора сообщения - подсказка зависит от возможностей
let selected_msg = app.get_selected_message();
let can_edit = selected_msg
.as_ref()
.map(|m| m.can_be_edited() && m.is_outgoing())
.unwrap_or(false);
let can_delete = selected_msg
.as_ref()
.map(|m| m.can_be_deleted_only_for_self() || m.can_be_deleted_for_all_users())
.unwrap_or(false);
let hint = match (can_edit, can_delete) {
(true, true) => "↑↓ · Enter ред. · r ответ · f перслть · y копир. · d удал. · Esc",
(true, false) => "↑↓ · Enter ред. · r ответ · f переслть · y копир. · Esc",
(false, true) => "↑↓ · r ответ · f переслать · y копир. · d удалить · Esc",
(false, false) => "↑↓ · r ответить · f переслать · y копировать · Esc",
};
(
Line::from(Span::styled(hint, Style::default().fg(Color::Cyan))),
" Выбор сообщения ",
)
} else if app.is_editing() {
// Режим редактирования
if app.message_input.is_empty() {
// Пустой инпут - показываем курсор и placeholder
let line = Line::from(vec![
Span::raw(""),
Span::styled("", Style::default().fg(Color::Magenta)),
Span::styled(" Введите новый текст...", Style::default().fg(Color::Gray)),
]);
(line, " Редактирование (Esc отмена) ")
} else {
// Текст с курсором
let line = render_input_with_cursor(
"",
&app.message_input,
app.cursor_position,
Color::Magenta,
);
(line, " Редактирование (Esc отмена) ")
}
} else if app.is_replying() {
// Режим ответа на сообщение
let reply_preview = app
.get_replying_to_message()
.map(|m| {
let sender = if m.is_outgoing() {
"Вы"
} else {
m.sender_name()
};
let text_preview: String = m.text().chars().take(30).collect();
let ellipsis = if m.text().chars().count() > 30 {
"..."
} else {
""
};
format!("{}: {}{}", sender, text_preview, ellipsis)
})
.unwrap_or_else(|| "...".to_string());
if app.message_input.is_empty() {
let line = Line::from(vec![
Span::styled("", Style::default().fg(Color::Cyan)),
Span::styled(reply_preview, Style::default().fg(Color::Gray)),
Span::raw(" "),
Span::styled("", Style::default().fg(Color::Yellow)),
]);
(line, " Ответ (Esc отмена) ")
} else {
let short_preview: String = reply_preview.chars().take(15).collect();
let prefix = format!("{} > ", short_preview);
let line = render_input_with_cursor(
&prefix,
&app.message_input,
app.cursor_position,
Color::Yellow,
);
(line, " Ответ (Esc отмена) ")
}
} else {
// Обычный режим
if app.message_input.is_empty() {
// Пустой инпут - показываем курсор и placeholder
let line = Line::from(vec![
Span::raw("> "),
Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(" Введите сообщение...", Style::default().fg(Color::Gray)),
]);
(line, "")
} else {
// Текст с курсором
let line = render_input_with_cursor(
"> ",
&app.message_input,
app.cursor_position,
Color::Yellow,
);
(line, "")
}
};
let input_block = if input_title.is_empty() {
Block::default().borders(Borders::ALL)
} else {
let title_color = if app.is_replying() || app.is_forwarding() {
Color::Cyan
} else {
Color::Magenta
};
Block::default()
.borders(Borders::ALL)
.title(input_title)
.title_style(
Style::default()
.fg(title_color)
.add_modifier(Modifier::BOLD),
)
};
let input = Paragraph::new(input_line)
.block(input_block)
.wrap(ratatui::widgets::Wrap { trim: false });
f.render_widget(input, area);
}
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Режим профиля
if app.is_profile_mode() {
@@ -435,13 +285,13 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Режим поиска по сообщениям
if app.is_message_search_mode() {
render_search_mode(f, area, app);
modals::render_search(f, area, app);
return;
}
// Режим просмотра закреплённых сообщений
if app.is_pinned_mode() {
render_pinned_mode(f, area, app);
modals::render_pinned(f, area, app);
return;
}
@@ -492,7 +342,7 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
render_message_list(f, message_chunks[2], app);
// Input box с wrap для длинного текста и блочным курсором
render_input_box(f, message_chunks[3], app);
compose_bar::render(f, message_chunks[3], app);
} else {
let empty = Paragraph::new("Выберите чат")
.block(Block::default().borders(Borders::ALL))
@@ -503,7 +353,7 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Модалка подтверждения удаления
if app.is_confirm_delete_shown() {
render_delete_confirm_modal(f, area);
modals::render_delete_confirm(f, area);
}
// Модалка выбора реакции
@@ -513,381 +363,7 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
..
} = &app.chat_state
{
render_reaction_picker_modal(f, area, available_reactions, *selected_index);
modals::render_reaction_picker(f, area, available_reactions, *selected_index);
}
}
/// Рендерит режим поиска по сообщениям
fn render_search_mode<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Извлекаем данные из ChatState
let (query, results, selected_index) =
if let crate::app::ChatState::SearchInChat {
query,
results,
selected_index,
} = &app.chat_state
{
(query.as_str(), results.as_slice(), *selected_index)
} else {
return; // Некорректное состояние, не рендерим
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Search input
Constraint::Min(0), // Search results
Constraint::Length(3), // Help bar
])
.split(area);
// Search input
let total = results.len();
let current = if total > 0 {
selected_index + 1
} else {
0
};
let input_line = if query.is_empty() {
Line::from(vec![
Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(" Введите текст для поиска...", Style::default().fg(Color::Gray)),
])
} else {
Line::from(vec![
Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
Span::styled(query, Style::default().fg(Color::White)),
Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(format!(" ({}/{})", current, total), Style::default().fg(Color::Gray)),
])
};
let search_input = Paragraph::new(input_line).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow))
.title(" Поиск по сообщениям ")
.title_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
);
f.render_widget(search_input, chunks[0]);
// Search results
let content_width = chunks[1].width.saturating_sub(2) as usize;
let mut lines: Vec<Line> = Vec::new();
if results.is_empty() {
if !query.is_empty() {
lines.push(Line::from(Span::styled(
"Ничего не найдено",
Style::default().fg(Color::Gray),
)));
}
} else {
for (idx, msg) in results.iter().enumerate() {
let is_selected = idx == selected_index;
// Пустая строка между результатами
if idx > 0 {
lines.push(Line::from(""));
}
// Маркер выбора, имя и дата
let marker = if is_selected { "" } else { " " };
let sender_color = if msg.is_outgoing() {
Color::Green
} else {
Color::Cyan
};
let sender_name = if msg.is_outgoing() {
"Вы".to_string()
} else {
msg.sender_name().to_string()
};
lines.push(Line::from(vec![
Span::styled(
marker,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{} ", sender_name),
Style::default()
.fg(sender_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("({})", crate::utils::format_datetime(msg.date())),
Style::default().fg(Color::Gray),
),
]));
// Текст сообщения (с переносом)
let msg_color = if is_selected {
Color::Yellow
} else {
Color::White
};
let max_width = content_width.saturating_sub(4);
let wrapped = wrap_text_with_offsets(msg.text(), max_width);
let wrapped_count = wrapped.len();
for wrapped_line in wrapped.into_iter().take(2) {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(wrapped_line.text, Style::default().fg(msg_color)),
]));
}
if wrapped_count > 2 {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("...", Style::default().fg(Color::Gray)),
]));
}
}
}
// Скролл к выбранному результату
let visible_height = chunks[1].height.saturating_sub(2) as usize;
let lines_per_result = 4;
let selected_line = selected_index * lines_per_result;
let scroll_offset = if selected_line > visible_height / 2 {
(selected_line - visible_height / 2) as u16
} else {
0
};
let results_widget = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow)),
)
.scroll((scroll_offset, 0));
f.render_widget(results_widget, chunks[1]);
// Help bar
let help_line = Line::from(vec![
Span::styled(
" ↑↓ ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw("навигация"),
Span::raw(" "),
Span::styled(
" n/N ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw("след./пред."),
Span::raw(" "),
Span::styled(
" Enter ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw("перейти"),
Span::raw(" "),
Span::styled(" Esc ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::raw("выход"),
]);
let help = Paragraph::new(help_line)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow)),
)
.alignment(Alignment::Center);
f.render_widget(help, chunks[2]);
}
/// Рендерит режим просмотра закреплённых сообщений
fn render_pinned_mode<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Извлекаем данные из ChatState
let (messages, selected_index) = if let crate::app::ChatState::PinnedMessages {
messages,
selected_index,
} = &app.chat_state
{
(messages.as_slice(), *selected_index)
} else {
return; // Некорректное состояние
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Header
Constraint::Min(0), // Pinned messages list
Constraint::Length(3), // Help bar
])
.split(area);
// Header
let total = messages.len();
let current = selected_index + 1;
let header_text = format!("📌 ЗАКРЕПЛЁННЫЕ ({}/{})", current, total);
let header = Paragraph::new(header_text)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta)),
)
.style(
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
);
f.render_widget(header, chunks[0]);
// Pinned messages list
let content_width = chunks[1].width.saturating_sub(2) as usize;
let mut lines: Vec<Line> = Vec::new();
for (idx, msg) in messages.iter().enumerate() {
let is_selected = idx == selected_index;
// Пустая строка между сообщениями
if idx > 0 {
lines.push(Line::from(""));
}
// Маркер выбора и имя отправителя
let marker = if is_selected { "" } else { " " };
let sender_color = if msg.is_outgoing() {
Color::Green
} else {
Color::Cyan
};
let sender_name = if msg.is_outgoing() {
"Вы".to_string()
} else {
msg.sender_name().to_string()
};
lines.push(Line::from(vec![
Span::styled(
marker,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{} ", sender_name),
Style::default()
.fg(sender_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("({})", crate::utils::format_datetime(msg.date())),
Style::default().fg(Color::Gray),
),
]));
// Текст сообщения (с переносом)
let msg_color = if is_selected {
Color::Yellow
} else {
Color::White
};
let max_width = content_width.saturating_sub(4);
let wrapped = wrap_text_with_offsets(msg.text(), max_width);
let wrapped_count = wrapped.len();
for wrapped_line in wrapped.into_iter().take(3) {
// Максимум 3 строки на сообщение
lines.push(Line::from(vec![
Span::raw(" "), // Отступ
Span::styled(wrapped_line.text, Style::default().fg(msg_color)),
]));
}
if wrapped_count > 3 {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("...", Style::default().fg(Color::Gray)),
]));
}
}
if lines.is_empty() {
lines.push(Line::from(Span::styled(
"Нет закреплённых сообщений",
Style::default().fg(Color::Gray),
)));
}
// Скролл к выбранному сообщению
let visible_height = chunks[1].height.saturating_sub(2) as usize;
let lines_per_msg = 5; // Примерно строк на сообщение
let selected_line = selected_index * lines_per_msg;
let scroll_offset = if selected_line > visible_height / 2 {
(selected_line - visible_height / 2) as u16
} else {
0
};
let messages_widget = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta)),
)
.scroll((scroll_offset, 0));
f.render_widget(messages_widget, chunks[1]);
// Help bar
let help_line = Line::from(vec![
Span::styled(
" ↑↓ ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw("навигация"),
Span::raw(" "),
Span::styled(
" Enter ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw("перейти"),
Span::raw(" "),
Span::styled(" Esc ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::raw("выход"),
]);
let help = Paragraph::new(help_line)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta)),
)
.alignment(Alignment::Center);
f.render_widget(help, chunks[2]);
}
/// Рендерит модалку подтверждения удаления
fn render_delete_confirm_modal(f: &mut Frame, area: Rect) {
components::modal::render_delete_confirm_modal(f, area);
}
/// Рендерит модалку выбора реакции
fn render_reaction_picker_modal(
f: &mut Frame,
area: Rect,
available_reactions: &[String],
selected_index: usize,
) {
components::render_emoji_picker(f, area, available_reactions, selected_index);
}

View File

@@ -1,10 +1,16 @@
//! UI rendering module.
//!
//! Routes rendering by screen (Loading → Auth → Main) and checks terminal size.
mod auth;
pub mod chat_list;
mod compose_bar;
pub mod components;
pub mod footer;
mod loading;
mod main_screen;
pub mod messages;
mod modals;
pub mod profile;
use crate::app::{App, AppScreen};

View File

@@ -0,0 +1,8 @@
//! Delete confirmation modal
use ratatui::{Frame, layout::Rect};
/// Renders delete confirmation modal
pub fn render(f: &mut Frame, area: Rect) {
crate::ui::components::modal::render_delete_confirm_modal(f, area);
}

17
src/ui/modals/mod.rs Normal file
View File

@@ -0,0 +1,17 @@
//! Modal dialog rendering modules
//!
//! Contains UI rendering for various modal dialogs:
//! - delete_confirm: Delete confirmation modal
//! - reaction_picker: Emoji reaction picker modal
//! - search: Message search modal
//! - pinned: Pinned messages viewer modal
pub mod delete_confirm;
pub mod reaction_picker;
pub mod search;
pub mod pinned;
pub use delete_confirm::render as render_delete_confirm;
pub use reaction_picker::render as render_reaction_picker;
pub use search::render as render_search;
pub use pinned::render as render_pinned;

93
src/ui/modals/pinned.rs Normal file
View File

@@ -0,0 +1,93 @@
//! Pinned messages viewer modal
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::ui::components::{render_message_item, calculate_scroll_offset, render_help_bar};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
/// Renders pinned messages mode
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Извлекаем данные из ChatState
let (messages, selected_index) = if let crate::app::ChatState::PinnedMessages {
messages,
selected_index,
} = &app.chat_state
{
(messages.as_slice(), *selected_index)
} else {
return; // Некорректное состояние
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Header
Constraint::Min(0), // Pinned messages list
Constraint::Length(3), // Help bar
])
.split(area);
// Header
let total = messages.len();
let current = selected_index + 1;
let header_text = format!("📌 ЗАКРЕПЛЁННЫЕ ({}/{})", current, total);
let header = Paragraph::new(header_text)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta)),
)
.style(
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
);
f.render_widget(header, chunks[0]);
// Pinned messages list
let content_width = chunks[1].width.saturating_sub(2) as usize;
let mut lines: Vec<Line> = Vec::new();
for (idx, msg) in messages.iter().enumerate() {
if idx > 0 {
lines.push(Line::from(""));
}
lines.extend(render_message_item(msg, idx == selected_index, content_width, 3));
}
if lines.is_empty() {
lines.push(Line::from(Span::styled(
"Нет закреплённых сообщений",
Style::default().fg(Color::Gray),
)));
}
// Скролл к выбранному сообщению
let scroll_offset = calculate_scroll_offset(selected_index, 5, chunks[1].height);
let messages_widget = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta)),
)
.scroll((scroll_offset, 0));
f.render_widget(messages_widget, chunks[1]);
// Help bar
let help = render_help_bar(
&[
("↑↓", "навигация", Color::Yellow),
("Enter", "перейти", Color::Green),
("Esc", "выход", Color::Red),
],
Color::Magenta,
);
f.render_widget(help, chunks[2]);
}

View File

@@ -0,0 +1,13 @@
//! Reaction picker modal
use ratatui::{Frame, layout::Rect};
/// Renders emoji reaction picker modal
pub fn render(
f: &mut Frame,
area: Rect,
available_reactions: &[String],
selected_index: usize,
) {
crate::ui::components::render_emoji_picker(f, area, available_reactions, selected_index);
}

117
src/ui/modals/search.rs Normal file
View File

@@ -0,0 +1,117 @@
//! Message search modal
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::ui::components::{render_message_item, calculate_scroll_offset, render_help_bar};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
/// Renders message search mode
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Извлекаем данные из ChatState
let (query, results, selected_index) =
if let crate::app::ChatState::SearchInChat {
query,
results,
selected_index,
} = &app.chat_state
{
(query.as_str(), results.as_slice(), *selected_index)
} else {
return; // Некорректное состояние, не рендерим
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Search input
Constraint::Min(0), // Search results
Constraint::Length(3), // Help bar
])
.split(area);
// Search input
let total = results.len();
let current = if total > 0 {
selected_index + 1
} else {
0
};
let input_line = if query.is_empty() {
Line::from(vec![
Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(" Введите текст для поиска...", Style::default().fg(Color::Gray)),
])
} else {
Line::from(vec![
Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
Span::styled(query, Style::default().fg(Color::White)),
Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(format!(" ({}/{})", current, total), Style::default().fg(Color::Gray)),
])
};
let search_input = Paragraph::new(input_line).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow))
.title(" Поиск по сообщениям ")
.title_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
);
f.render_widget(search_input, chunks[0]);
// Search results
let content_width = chunks[1].width.saturating_sub(2) as usize;
let mut lines: Vec<Line> = Vec::new();
if results.is_empty() {
if !query.is_empty() {
lines.push(Line::from(Span::styled(
"Ничего не найдено",
Style::default().fg(Color::Gray),
)));
}
} else {
for (idx, msg) in results.iter().enumerate() {
if idx > 0 {
lines.push(Line::from(""));
}
lines.extend(render_message_item(msg, idx == selected_index, content_width, 2));
}
}
// Скролл к выбранному результату
let scroll_offset = calculate_scroll_offset(selected_index, 4, chunks[1].height);
let results_widget = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow)),
)
.scroll((scroll_offset, 0));
f.render_widget(results_widget, chunks[1]);
// Help bar
let help = render_help_bar(
&[
("↑↓", "навигация", Color::Yellow),
("n/N", "след./пред.", Color::Yellow),
("Enter", "перейти", Color::Green),
("Esc", "выход", Color::Red),
],
Color::Yellow,
);
f.render_widget(help, chunks[2]);
}

View File

@@ -1,4 +1,5 @@
use crate::app::App;
use crate::app::methods::modal::ModalMethods;
use crate::tdlib::TdClientTrait;
use crate::tdlib::ProfileInfo;
use ratatui::{