Add playback position ticker in event loop with 1s UI refresh rate, integrate VoiceCache for downloaded voice files, add [audio] config section (cache_size_mb, auto_download_voice), and render progress bar with waveform visualization in message bubbles. Fix race conditions in AudioPlayer: add `starting` flag to prevent false `is_stopped()` during ffplay startup, guard pid cleanup so old threads don't overwrite newer process pids. Implement `resume_from()` with ffplay `-ss` for real audio seek on unpause (-1s rewind). Kill ffplay on app exit via `stop_playback()` in shutdown + Drop impl. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
438 lines
13 KiB
Rust
438 lines
13 KiB
Rust
//! 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;
|
||
pub mod methods;
|
||
|
||
pub use chat_filter::{ChatFilter, ChatFilterCriteria};
|
||
pub use chat_state::ChatState;
|
||
pub use state::AppScreen;
|
||
pub use methods::*;
|
||
|
||
use crate::tdlib::{ChatInfo, TdClient, TdClientTrait};
|
||
use crate::types::ChatId;
|
||
use ratatui::widgets::ListState;
|
||
|
||
/// Main application state for the Telegram TUI client.
|
||
///
|
||
/// Manages all application state including authentication, chats, messages,
|
||
/// and UI state. Integrates with TDLib через `TdClient` and handles user input.
|
||
///
|
||
/// # State Machine
|
||
///
|
||
/// The app uses a type-safe state machine (`ChatState`) for chat-related operations:
|
||
/// - `Normal` - default state
|
||
/// - `MessageSelection` - selecting a message
|
||
/// - `Editing` - editing a message
|
||
/// - `Reply` - replying to a message
|
||
/// - `Forward` - forwarding a message
|
||
/// - `DeleteConfirmation` - confirming deletion
|
||
/// - `ReactionPicker` - choosing a reaction
|
||
/// - `Profile` - viewing profile
|
||
/// - `SearchInChat` - searching within chat
|
||
/// - `PinnedMessages` - viewing pinned messages
|
||
///
|
||
/// # Examples
|
||
///
|
||
/// ```no_run
|
||
/// use tele_tui::app::App;
|
||
/// use tele_tui::app::methods::navigation::NavigationMethods;
|
||
/// use tele_tui::config::Config;
|
||
///
|
||
/// let config = Config::default();
|
||
/// let mut app = App::new(config);
|
||
///
|
||
/// // Navigate through chats
|
||
/// app.next_chat();
|
||
/// app.previous_chat();
|
||
///
|
||
/// // Open a chat
|
||
/// app.select_current_chat();
|
||
/// ```
|
||
pub struct App<T: TdClientTrait = TdClient> {
|
||
// Core (config - readonly через getter)
|
||
config: crate::config::Config,
|
||
pub screen: AppScreen,
|
||
pub td_client: T,
|
||
/// Состояние чата - type-safe state machine (новое!)
|
||
pub chat_state: ChatState,
|
||
// Auth state (приватные, доступ через геттеры)
|
||
phone_input: String,
|
||
code_input: String,
|
||
password_input: String,
|
||
pub error_message: Option<String>,
|
||
pub status_message: Option<String>,
|
||
// Main app state (используются часто)
|
||
pub chats: Vec<ChatInfo>,
|
||
pub chat_list_state: ListState,
|
||
pub selected_chat_id: Option<ChatId>,
|
||
pub message_input: String,
|
||
/// Позиция курсора в message_input (в символах)
|
||
pub cursor_position: usize,
|
||
pub message_scroll_offset: usize,
|
||
/// None = All (основной список), Some(id) = папка с id
|
||
pub selected_folder_id: Option<i32>,
|
||
pub is_loading: bool,
|
||
// Search state
|
||
pub is_searching: bool,
|
||
pub search_query: String,
|
||
/// Флаг для оптимизации рендеринга - перерисовывать только при изменениях
|
||
pub needs_redraw: bool,
|
||
// Typing indicator
|
||
/// Время последней отправки typing status (для throttling)
|
||
pub last_typing_sent: Option<std::time::Instant>,
|
||
// Image support
|
||
#[cfg(feature = "images")]
|
||
pub image_cache: Option<crate::media::cache::ImageCache>,
|
||
/// Renderer для inline preview в чате (Halfblocks - быстро)
|
||
#[cfg(feature = "images")]
|
||
pub inline_image_renderer: Option<crate::media::image_renderer::ImageRenderer>,
|
||
/// Renderer для modal просмотра (iTerm2/Sixel - высокое качество)
|
||
#[cfg(feature = "images")]
|
||
pub modal_image_renderer: Option<crate::media::image_renderer::ImageRenderer>,
|
||
/// Состояние модального окна просмотра изображения
|
||
#[cfg(feature = "images")]
|
||
pub image_modal: Option<crate::tdlib::ImageModalState>,
|
||
/// Время последнего рендеринга изображений (для throttling до 15 FPS)
|
||
#[cfg(feature = "images")]
|
||
pub last_image_render_time: Option<std::time::Instant>,
|
||
// Voice playback
|
||
/// Аудиопроигрыватель для голосовых сообщений (rodio)
|
||
pub audio_player: Option<crate::audio::AudioPlayer>,
|
||
/// Кэш голосовых файлов (LRU, max 100 MB)
|
||
pub voice_cache: Option<crate::audio::VoiceCache>,
|
||
/// Состояние текущего воспроизведения
|
||
pub playback_state: Option<crate::tdlib::PlaybackState>,
|
||
/// Время последнего тика для обновления позиции воспроизведения
|
||
pub last_playback_tick: Option<std::time::Instant>,
|
||
}
|
||
|
||
impl<T: TdClientTrait> App<T> {
|
||
/// Creates a new App instance with the given configuration and client.
|
||
///
|
||
/// Sets up empty chat list and configures the app to start on the Loading screen.
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `config` - Application configuration loaded from config.toml
|
||
/// * `td_client` - TDLib client instance (real or fake for tests)
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// A new `App` instance ready to start authentication.
|
||
pub fn with_client(config: crate::config::Config, td_client: T) -> App<T> {
|
||
let mut state = ListState::default();
|
||
state.select(Some(0));
|
||
|
||
let audio_cache_size_mb = config.audio.cache_size_mb;
|
||
|
||
#[cfg(feature = "images")]
|
||
let image_cache = Some(crate::media::cache::ImageCache::new(
|
||
config.images.cache_size_mb,
|
||
));
|
||
#[cfg(feature = "images")]
|
||
let inline_image_renderer = crate::media::image_renderer::ImageRenderer::new_fast();
|
||
#[cfg(feature = "images")]
|
||
let modal_image_renderer = crate::media::image_renderer::ImageRenderer::new();
|
||
|
||
App {
|
||
config,
|
||
screen: AppScreen::Loading,
|
||
td_client,
|
||
chat_state: ChatState::Normal,
|
||
phone_input: String::new(),
|
||
code_input: String::new(),
|
||
password_input: String::new(),
|
||
error_message: None,
|
||
status_message: Some("Инициализация TDLib...".to_string()),
|
||
chats: Vec::new(),
|
||
chat_list_state: state,
|
||
selected_chat_id: None,
|
||
message_input: String::new(),
|
||
cursor_position: 0,
|
||
message_scroll_offset: 0,
|
||
selected_folder_id: None, // None = All
|
||
is_loading: true,
|
||
is_searching: false,
|
||
search_query: String::new(),
|
||
needs_redraw: true,
|
||
last_typing_sent: None,
|
||
#[cfg(feature = "images")]
|
||
image_cache,
|
||
#[cfg(feature = "images")]
|
||
inline_image_renderer,
|
||
#[cfg(feature = "images")]
|
||
modal_image_renderer,
|
||
#[cfg(feature = "images")]
|
||
image_modal: None,
|
||
#[cfg(feature = "images")]
|
||
last_image_render_time: None,
|
||
// Voice playback
|
||
audio_player: crate::audio::AudioPlayer::new().ok(),
|
||
voice_cache: crate::audio::VoiceCache::new(audio_cache_size_mb).ok(),
|
||
playback_state: None,
|
||
last_playback_tick: None,
|
||
}
|
||
}
|
||
|
||
/// Получить команду из KeyEvent используя настроенные keybindings.
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `key` - KeyEvent от пользователя
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// `Some(Command)` если найдена команда для этой клавиши, `None` если нет
|
||
pub fn get_command(&self, key: crossterm::event::KeyEvent) -> Option<crate::config::Command> {
|
||
self.config.keybindings.get_command(&key)
|
||
}
|
||
|
||
/// Get the selected chat ID as i64
|
||
pub fn get_selected_chat_id(&self) -> Option<i64> {
|
||
self.selected_chat_id.map(|id| id.as_i64())
|
||
}
|
||
|
||
/// Останавливает воспроизведение голосового и сбрасывает состояние
|
||
pub fn stop_playback(&mut self) {
|
||
if let Some(ref player) = self.audio_player {
|
||
player.stop();
|
||
}
|
||
self.playback_state = None;
|
||
self.last_playback_tick = None;
|
||
self.status_message = None;
|
||
}
|
||
|
||
/// Get the selected chat info
|
||
pub fn get_selected_chat(&self) -> Option<&ChatInfo> {
|
||
self.selected_chat_id
|
||
.and_then(|id| self.chats.iter().find(|c| c.id == id))
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
// ========== Getter/Setter методы для инкапсуляции ==========
|
||
|
||
// Config
|
||
pub fn config(&self) -> &crate::config::Config {
|
||
&self.config
|
||
}
|
||
|
||
// Screen
|
||
pub fn screen(&self) -> &AppScreen {
|
||
&self.screen
|
||
}
|
||
|
||
pub fn set_screen(&mut self, screen: AppScreen) {
|
||
self.screen = screen;
|
||
}
|
||
|
||
// Auth state
|
||
pub fn phone_input(&self) -> &str {
|
||
&self.phone_input
|
||
}
|
||
|
||
pub fn phone_input_mut(&mut self) -> &mut String {
|
||
&mut self.phone_input
|
||
}
|
||
|
||
pub fn set_phone_input(&mut self, input: String) {
|
||
self.phone_input = input;
|
||
}
|
||
|
||
pub fn code_input(&self) -> &str {
|
||
&self.code_input
|
||
}
|
||
|
||
pub fn code_input_mut(&mut self) -> &mut String {
|
||
&mut self.code_input
|
||
}
|
||
|
||
pub fn set_code_input(&mut self, input: String) {
|
||
self.code_input = input;
|
||
}
|
||
|
||
pub fn password_input(&self) -> &str {
|
||
&self.password_input
|
||
}
|
||
|
||
pub fn password_input_mut(&mut self) -> &mut String {
|
||
&mut self.password_input
|
||
}
|
||
|
||
pub fn set_password_input(&mut self, input: String) {
|
||
self.password_input = input;
|
||
}
|
||
|
||
pub fn error_message(&self) -> Option<&str> {
|
||
self.error_message.as_deref()
|
||
}
|
||
|
||
pub fn set_error_message(&mut self, message: Option<String>) {
|
||
self.error_message = message;
|
||
}
|
||
|
||
pub fn status_message(&self) -> Option<&str> {
|
||
self.status_message.as_deref()
|
||
}
|
||
|
||
pub fn set_status_message(&mut self, message: Option<String>) {
|
||
self.status_message = message;
|
||
}
|
||
|
||
// Main app state
|
||
pub fn chats(&self) -> &[ChatInfo] {
|
||
&self.chats
|
||
}
|
||
|
||
pub fn chats_mut(&mut self) -> &mut Vec<ChatInfo> {
|
||
&mut self.chats
|
||
}
|
||
|
||
pub fn set_chats(&mut self, chats: Vec<ChatInfo>) {
|
||
self.chats = chats;
|
||
}
|
||
|
||
pub fn chat_list_state(&self) -> &ListState {
|
||
&self.chat_list_state
|
||
}
|
||
|
||
pub fn chat_list_state_mut(&mut self) -> &mut ListState {
|
||
&mut self.chat_list_state
|
||
}
|
||
|
||
pub fn selected_chat_id(&self) -> Option<ChatId> {
|
||
self.selected_chat_id
|
||
}
|
||
|
||
pub fn set_selected_chat_id(&mut self, id: Option<ChatId>) {
|
||
self.selected_chat_id = id;
|
||
}
|
||
|
||
pub fn message_input(&self) -> &str {
|
||
&self.message_input
|
||
}
|
||
|
||
pub fn message_input_mut(&mut self) -> &mut String {
|
||
&mut self.message_input
|
||
}
|
||
|
||
pub fn set_message_input(&mut self, input: String) {
|
||
self.message_input = input;
|
||
}
|
||
|
||
pub fn cursor_position(&self) -> usize {
|
||
self.cursor_position
|
||
}
|
||
|
||
pub fn set_cursor_position(&mut self, pos: usize) {
|
||
self.cursor_position = pos;
|
||
}
|
||
|
||
pub fn message_scroll_offset(&self) -> usize {
|
||
self.message_scroll_offset
|
||
}
|
||
|
||
pub fn set_message_scroll_offset(&mut self, offset: usize) {
|
||
self.message_scroll_offset = offset;
|
||
}
|
||
|
||
pub fn selected_folder_id(&self) -> Option<i32> {
|
||
self.selected_folder_id
|
||
}
|
||
|
||
pub fn set_selected_folder_id(&mut self, id: Option<i32>) {
|
||
self.selected_folder_id = id;
|
||
}
|
||
|
||
pub fn is_loading(&self) -> bool {
|
||
self.is_loading
|
||
}
|
||
|
||
pub fn set_loading(&mut self, loading: bool) {
|
||
self.is_loading = loading;
|
||
}
|
||
|
||
// Search state
|
||
pub fn is_searching(&self) -> bool {
|
||
self.is_searching
|
||
}
|
||
|
||
pub fn set_searching(&mut self, searching: bool) {
|
||
self.is_searching = searching;
|
||
}
|
||
|
||
pub fn search_query(&self) -> &str {
|
||
&self.search_query
|
||
}
|
||
|
||
pub fn search_query_mut(&mut self) -> &mut String {
|
||
&mut self.search_query
|
||
}
|
||
|
||
pub fn set_search_query(&mut self, query: String) {
|
||
self.search_query = query;
|
||
}
|
||
|
||
// Redraw flag
|
||
pub fn needs_redraw(&self) -> bool {
|
||
self.needs_redraw
|
||
}
|
||
|
||
pub fn set_needs_redraw(&mut self, redraw: bool) {
|
||
self.needs_redraw = redraw;
|
||
}
|
||
|
||
pub fn mark_for_redraw(&mut self) {
|
||
self.needs_redraw = true;
|
||
}
|
||
|
||
// Typing indicator
|
||
pub fn last_typing_sent(&self) -> Option<std::time::Instant> {
|
||
self.last_typing_sent
|
||
}
|
||
|
||
pub fn set_last_typing_sent(&mut self, time: Option<std::time::Instant>) {
|
||
self.last_typing_sent = time;
|
||
}
|
||
|
||
pub fn update_last_typing_sent(&mut self) {
|
||
self.last_typing_sent = Some(std::time::Instant::now());
|
||
}
|
||
}
|
||
|
||
// Convenience constructor for real TdClient (production use)
|
||
impl App<TdClient> {
|
||
/// Creates a new App instance with the given configuration and a real TDLib client.
|
||
///
|
||
/// This is a convenience method for production use that automatically creates
|
||
/// a new TdClient instance.
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `config` - Application configuration loaded from config.toml
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// A new `App<TdClient>` instance ready to start authentication.
|
||
pub fn new(config: crate::config::Config) -> App<TdClient> {
|
||
App::with_client(config, TdClient::new())
|
||
}
|
||
}
|