diff --git a/.serena/project.yml b/.serena/project.yml index 28d6c2a..4f4b450 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -1,23 +1,30 @@ + + # list of languages for which language servers are started; choose from: -# al bash clojure cpp csharp -# csharp_omnisharp dart elixir elm erlang -# fortran fsharp go groovy haskell -# java julia kotlin lua markdown -# matlab nix pascal perl php -# powershell python python_jedi r rego -# ruby ruby_solargraph rust scala swift -# terraform toml typescript typescript_vts vue -# yaml zig +# al angular ansible bash clojure +# cpp cpp_ccls crystal csharp csharp_omnisharp +# dart elixir elm erlang fortran +# fsharp go groovy haskell haxe +# hlsl html java json julia +# kotlin lean4 lua luau markdown +# matlab msl nix ocaml pascal +# perl php php_phpactor powershell python +# python_jedi python_ty r rego ruby +# ruby_solargraph rust scala scss solidity +# swift systemverilog terraform toml typescript +# typescript_vts vue yaml zig # (This list may be outdated. For the current list, see values of Language enum here: # https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py # For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) # Note: # - For C, use cpp # - For JavaScript, use typescript +# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) # - For Free Pascal/Lazarus, use pascal # Special requirements: -# - csharp: Requires the presence of a .sln file in the project folder. -# - pascal: Requires Free Pascal Compiler (fpc) and optionally Lazarus. +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers # When using multiple languages, the first language server that supports a given file will be used for that file. # The first language is the default language and the respective language server will be used as a fallback. # Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. @@ -31,8 +38,9 @@ encoding: "utf-8" # whether to use project's .gitignore files to ignore files ignore_all_files_in_gitignore: true -# list of additional paths to ignore in all projects -# same syntax as gitignore, so you can use * and ** +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. ignored_paths: [] # whether the project is in read-only mode @@ -40,45 +48,9 @@ ignored_paths: [] # Added on 2025-04-18 read_only: false -# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. -# Below is the complete list of tools for convenience. -# To make sure you have the latest list of tools, and to view their descriptions, -# execute `uv run scripts/print_tool_overview.py`. -# -# * `activate_project`: Activates a project by name. -# * `check_onboarding_performed`: Checks whether project onboarding was already performed. -# * `create_text_file`: Creates/overwrites a file in the project directory. -# * `delete_lines`: Deletes a range of lines within a file. -# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. -# * `execute_shell_command`: Executes a shell command. -# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. -# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). -# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). -# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. -# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. -# * `initial_instructions`: Gets the initial instructions for the current project. -# Should only be used in settings where the system prompt cannot be set, -# e.g. in clients you have no control over, like Claude Desktop. -# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. -# * `insert_at_line`: Inserts content at a given line in a file. -# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. -# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). -# * `list_memories`: Lists memories in Serena's project-specific memory store. -# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). -# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). -# * `read_file`: Reads a file within the project directory. -# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. -# * `remove_project`: Removes a project from the Serena configuration. -# * `replace_lines`: Replaces a range of lines within a file with new content. -# * `replace_symbol_body`: Replaces the full definition of a symbol. -# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. -# * `search_for_pattern`: Performs a search for a pattern in the project. -# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. -# * `switch_modes`: Activates modes by providing a list of their names -# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. -# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. -# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. -# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html excluded_tools: [] # initial prompt for the project. It will always be given to the LLM upon activating the project @@ -87,7 +59,9 @@ initial_prompt: "" # the name by which the project can be referenced within Serena project_name: "tele-tui" -# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default) +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html included_optional_tools: [] # list of mode names to that are always to be included in the set of active modes @@ -98,19 +72,25 @@ included_optional_tools: [] # Set this to a list of mode names to always include the respective modes for this project. base_modes: -# list of mode names that are to be activated by default. -# The full set of modes to be activated is base_modes + default_modes. -# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# list of mode names that are to be activated by default, overriding the setting in the global configuration. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. # Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply +# for this project. # This setting can, in turn, be overridden by CLI parameters (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes default_modes: # fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. # This cannot be combined with non-empty excluded_tools or included_optional_tools. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html fixed_tools: [] -# override of the corresponding setting in serena_config.yml, see the documentation there. -# If null or missing, the value from the global config is used. +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. symbol_info_budget: # The language backend to use for this project. @@ -119,3 +99,42 @@ symbol_info_budget: # Note: the backend is fixed at startup. If a project with a different backend # is activated post-init, an error will be returned. language_backend: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. +# No documentation on options means no options are available. +ls_specific_settings: {} + +# list of mode names to be activated additionally for this project, e.g. ["query-projects"] +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +added_modes: + +# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos). +# Paths can be absolute or relative to the project root. +# Each folder is registered as an LSP workspace folder, enabling language servers to discover +# symbols and references across package boundaries. +# Currently supported for: TypeScript. +# Example: +# additional_workspace_folders: +# - ../sibling-package +# - ../shared-lib +additional_workspace_folders: [] diff --git a/src/app/methods/navigation.rs b/src/app/methods/navigation.rs index bec7dba..2155a46 100644 --- a/src/app/methods/navigation.rs +++ b/src/app/methods/navigation.rs @@ -83,6 +83,7 @@ impl NavigationMethods for App { self.message_scroll_offset = 0; self.last_typing_sent = None; self.pending_chat_init = None; + self.chat_init_rx = None; // Останавливаем фоновую загрузку фото (drop receiver) #[cfg(feature = "images")] { diff --git a/src/app/mod.rs b/src/app/mod.rs index f522410..1e22eaa 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -16,7 +16,7 @@ pub use state::AppScreen; use crate::accounts::AccountProfile; use crate::tdlib::{ChatInfo, TdClient, TdClientTrait}; -use crate::types::ChatId; +use crate::types::{ChatId, MessageId}; use ratatui::widgets::ListState; use std::path::PathBuf; @@ -33,6 +33,17 @@ pub struct PendingImageOpen { pub photo_height: i32, } +/// Result from background chat initialization tasks. +#[derive(Debug, Clone)] +pub enum ChatInitEvent { + ReplyInfoLoaded { + chat_id: ChatId, + message_id: MessageId, + sender_name: String, + text: String, + }, +} + /// State of the account switcher modal overlay. #[derive(Debug, Clone)] pub enum AccountSwitcherState { @@ -149,14 +160,16 @@ pub struct App { pub current_account_name: String, /// Pending account switch: (account_name, db_path) pub pending_account_switch: Option<(String, PathBuf)>, - /// Pending background chat init (reply info, pinned) after fast open + /// Pending background chat init (reply info, photos) after fast open pub pending_chat_init: Option, + /// Receiver for background chat initialization results + pub chat_init_rx: Option>, /// Receiver for background photo downloads (file_id, result path) #[cfg(feature = "images")] pub photo_download_rx: Option)>>, // Voice playback - /// Аудиопроигрыватель для голосовых сообщений (rodio) + /// Аудиопроигрыватель для голосовых сообщений (ffplay) pub audio_player: Option, /// Кэш голосовых файлов (LRU, max 100 MB) pub voice_cache: Option, @@ -223,6 +236,7 @@ impl App { current_account_name: "default".to_string(), pending_account_switch: None, pending_chat_init: None, + chat_init_rx: None, #[cfg(feature = "images")] photo_download_rx: None, #[cfg(feature = "images")] diff --git a/src/audio/mod.rs b/src/audio/mod.rs index b0890ad..8680858 100644 --- a/src/audio/mod.rs +++ b/src/audio/mod.rs @@ -1,7 +1,7 @@ //! Audio playback module for voice messages. //! //! Provides: -//! - AudioPlayer: rodio-based playback with play/pause/stop/volume controls +//! - AudioPlayer: ffplay-based playback with play/pause/seek controls //! - VoiceCache: LRU cache for downloaded OGG voice files pub mod cache; diff --git a/src/audio/player.rs b/src/audio/player.rs index 6fceb9b..d4b1cb6 100644 --- a/src/audio/player.rs +++ b/src/audio/player.rs @@ -23,12 +23,15 @@ pub struct AudioPlayer { impl AudioPlayer { /// Creates a new AudioPlayer pub fn new() -> Result { - Command::new("which") + let ffplay_check = Command::new("which") .arg("ffplay") .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .output() .map_err(|_| "ffplay not found (install ffmpeg)".to_string())?; + if !ffplay_check.status.success() { + return Err("ffplay not found (install ffmpeg)".to_string()); + } Ok(Self { current_pid: Arc::new(Mutex::new(None)), @@ -48,44 +51,48 @@ impl AudioPlayer { self.stop(); let path_owned = path.as_ref().to_path_buf(); - *self.current_path.lock().unwrap() = Some(path_owned.clone()); *self.starting.lock().unwrap() = true; let current_pid = self.current_pid.clone(); let paused = self.paused.clone(); let starting = self.starting.clone(); - std::thread::spawn(move || { - let mut cmd = Command::new("ffplay"); - cmd.arg("-nodisp") - .arg("-autoexit") - .arg("-loglevel") - .arg("quiet"); + let mut cmd = Command::new("ffplay"); + cmd.arg("-nodisp") + .arg("-autoexit") + .arg("-loglevel") + .arg("quiet"); - if start_secs > 0.0 { - cmd.arg("-ss").arg(format!("{:.1}", start_secs)); + if start_secs > 0.0 { + cmd.arg("-ss").arg(format!("{:.1}", start_secs)); + } + + let mut child = match cmd + .arg(&path_owned) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + { + Ok(child) => child, + Err(e) => { + *self.starting.lock().unwrap() = false; + return Err(format!("failed to start ffplay: {}", e)); } + }; - if let Ok(mut child) = cmd - .arg(&path_owned) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn() - { - let pid = child.id(); - *current_pid.lock().unwrap() = Some(pid); + let pid = child.id(); + *self.current_path.lock().unwrap() = Some(path_owned); + *current_pid.lock().unwrap() = Some(pid); + *paused.lock().unwrap() = false; + *starting.lock().unwrap() = false; + + std::thread::spawn(move || { + let _ = child.wait(); + + // Обнуляем только если это наш pid (новый play мог уже заменить его) + let mut pid_guard = current_pid.lock().unwrap(); + if *pid_guard == Some(pid) { + *pid_guard = None; *paused.lock().unwrap() = false; - *starting.lock().unwrap() = false; - - let _ = child.wait(); - - // Обнуляем только если это наш pid (новый play мог уже заменить его) - let mut pid_guard = current_pid.lock().unwrap(); - if *pid_guard == Some(pid) { - *pid_guard = None; - *paused.lock().unwrap() = false; - } - } else { - *starting.lock().unwrap() = false; } }); diff --git a/src/config/keybindings.rs b/src/config/keybindings.rs index af4eebe..8e58584 100644 --- a/src/config/keybindings.rs +++ b/src/config/keybindings.rs @@ -71,6 +71,49 @@ pub enum Command { 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 { @@ -114,13 +157,33 @@ pub struct Keybindings { impl Keybindings { /// Ищет команду по клавише pub fn get_command(&self, event: &KeyEvent) -> Option { - for (command, bindings) in &self.bindings { - if bindings.iter().any(|binding| binding.matches(event)) { + 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)> { + let mut by_key: HashMap> = 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 { @@ -133,7 +196,7 @@ impl Default for Keybindings { vec![ KeyBinding::new(KeyCode::Up), KeyBinding::new(KeyCode::Char('k')), - KeyBinding::new(KeyCode::Char('р')), // RU (custom mapping, not standard ЙЦУКЕН) + KeyBinding::new(KeyCode::Char('л')), // RU ], ); bindings.insert( @@ -149,7 +212,7 @@ impl Default for Keybindings { vec![ KeyBinding::new(KeyCode::Left), KeyBinding::new(KeyCode::Char('h')), - KeyBinding::new(KeyCode::Char('л')), // RU (custom mapping, not standard ЙЦУКЕН) + KeyBinding::new(KeyCode::Char('р')), // RU ], ); bindings.insert( @@ -264,8 +327,9 @@ impl Default for Keybindings { // Voice playback bindings.insert(Command::TogglePlayback, vec![KeyBinding::new(KeyCode::Char(' '))]); - bindings.insert(Command::SeekForward, vec![KeyBinding::new(KeyCode::Right)]); - bindings.insert(Command::SeekBackward, vec![KeyBinding::new(KeyCode::Left)]); + // 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)]); @@ -279,13 +343,7 @@ impl Default for Keybindings { KeyBinding::with_ctrl(KeyCode::Char('w')), ], ); - bindings.insert( - Command::MoveToStart, - vec![ - KeyBinding::new(KeyCode::Home), - KeyBinding::with_ctrl(KeyCode::Char('a')), - ], - ); + bindings.insert(Command::MoveToStart, vec![KeyBinding::new(KeyCode::Home)]); bindings.insert( Command::MoveToEnd, vec![ @@ -307,8 +365,8 @@ impl Default for Keybindings { bindings.insert( Command::OpenProfile, vec![ - KeyBinding::with_ctrl(KeyCode::Char('u')), // Ctrl+U instead of Ctrl+I - KeyBinding::with_ctrl(KeyCode::Char('г')), // RU + KeyBinding::with_ctrl(KeyCode::Char('i')), + KeyBinding::with_ctrl(KeyCode::Char('ш')), // RU ], ); @@ -462,7 +520,8 @@ mod tests { // Проверяем навигацию 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::MoveUp)); + assert_eq!(kb.get_command(&KeyEvent::from(KeyCode::Char('р'))), Some(Command::MoveLeft)); } #[test] @@ -485,4 +544,11 @@ mod tests { 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); + } } diff --git a/src/input/handlers/chat.rs b/src/input/handlers/chat.rs index 517a265..39e1c3c 100644 --- a/src/input/handlers/chat.rs +++ b/src/input/handlers/chat.rs @@ -379,7 +379,7 @@ pub async fn handle_open_chat_keyboard_input(app: &mut App, } KeyCode::Char(c) => { // Игнорируем символы с Ctrl/Alt модификаторами (кроме Shift) - // Это позволяет обрабатывать хоткеи типа Ctrl+U для профиля + // Это позволяет обрабатывать хоткеи типа Ctrl+I для профиля if key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.contains(KeyModifiers::ALT) { @@ -602,14 +602,11 @@ async fn handle_view_image(app: &mut App) { let client_id = app.td_client.client_id(); tokio::spawn(async move { let result = tokio::time::timeout(Duration::from_secs(30), async { - match tdlib_rs::functions::download_file( - file_id, 1, 0, 0, true, client_id, - ) - .await + match tdlib_rs::functions::download_file(file_id, 1, 0, 0, true, client_id) + .await { Ok(tdlib_rs::enums::File::File(f)) - if f.local.is_downloading_completed - && !f.local.path.is_empty() => + if f.local.is_downloading_completed && !f.local.path.is_empty() => { Ok(f.local.path) } @@ -618,8 +615,7 @@ async fn handle_view_image(app: &mut App) { } }) .await; - let result = - result.unwrap_or_else(|_| Err("Таймаут загрузки".to_string())); + let result = result.unwrap_or_else(|_| Err("Таймаут загрузки".to_string())); let _ = tx.send((file_id, result)); }); } diff --git a/src/input/handlers/chat_loader.rs b/src/input/handlers/chat_loader.rs index 1388da4..abf3223 100644 --- a/src/input/handlers/chat_loader.rs +++ b/src/input/handlers/chat_loader.rs @@ -1,22 +1,24 @@ //! Chat loading logic — all three phases of message loading //! //! - Phase 1: `open_chat_and_load_data` — fast initial load (50 messages) -//! - Phase 2: `process_pending_chat_init` — background tasks (reply info, pinned, photos) +//! - Phase 2: `process_pending_chat_init` — starts background tasks (reply info, photos) //! - Phase 3: `load_older_messages_if_needed` — lazy load on scroll up use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods}; -use crate::app::App; use crate::app::InputMode; +use crate::app::{App, ChatInitEvent}; +use crate::tdlib::message_conversion::{extract_content_text, extract_sender_name}; use crate::tdlib::TdClientTrait; use crate::types::{ChatId, MessageId}; -use crate::utils::{with_timeout, with_timeout_ignore, with_timeout_msg}; +use crate::utils::{with_timeout, with_timeout_msg}; use std::time::Duration; +use tokio::sync::mpsc::error::TryRecvError; /// Открывает чат и загружает последние сообщения (быстро). /// /// Загружает только 50 последних сообщений для мгновенного отображения. -/// Фоновые задачи (reply info, pinned, photos) откладываются в `pending_chat_init` -/// и выполняются на следующем тике main loop. +/// Фоновые задачи (reply info, photos) откладываются в `pending_chat_init` +/// и стартуют после первого redraw. /// /// При ошибке устанавливает error_message и очищает status_message. pub async fn open_chat_and_load_data(app: &mut App, chat_id: i64) { @@ -62,7 +64,7 @@ pub async fn open_chat_and_load_data(app: &mut App, chat_id app.input_mode = InputMode::Normal; app.start_message_selection(); - // Фоновые задачи (reply info, pinned, photos) — на следующем тике main loop + // Фоновые задачи (reply info, photos) — после первого redraw app.pending_chat_init = Some(ChatId::new(chat_id)); } Err(e) => { @@ -72,21 +74,71 @@ pub async fn open_chat_and_load_data(app: &mut App, chat_id } } -/// Выполняет фоновую инициализацию после открытия чата. +/// Запускает фоновую инициализацию после открытия чата. /// -/// Вызывается на следующем тике main loop после `open_chat_and_load_data`. -/// Загружает reply info, закреплённое сообщение и начинает авто-загрузку фото. -pub async fn process_pending_chat_init(app: &mut App, chat_id: ChatId) { - // Загружаем недостающие reply info (игнорируем ошибки) - with_timeout_ignore(Duration::from_secs(5), app.td_client.fetch_missing_reply_info()) - .await; +/// Вызывается после первого redraw после `open_chat_and_load_data`. +/// Не блокирует UI loop: TDLib запросы выполняются в отдельных Tokio tasks, +/// а готовые результаты применяются через `process_chat_init_events`. +pub fn process_pending_chat_init(app: &mut App, chat_id: ChatId) { + app.chat_init_rx = None; - // Загружаем последнее закреплённое сообщение (игнорируем ошибки) - with_timeout_ignore( - Duration::from_secs(2), - app.td_client.load_current_pinned_message(chat_id), - ) - .await; + let mut reply_message_ids: Vec = app + .td_client + .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(); + reply_message_ids.sort_unstable(); + reply_message_ids.dedup(); + + if !reply_message_ids.is_empty() { + let client_id = app.td_client.client_id(); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel::(); + app.chat_init_rx = Some(rx); + + for message_id in reply_message_ids { + let tx = tx.clone(); + tokio::spawn(async move { + let result = tokio::time::timeout(Duration::from_secs(5), async { + let Ok(original_msg_enum) = tdlib_rs::functions::get_message( + chat_id.as_i64(), + message_id.as_i64(), + client_id, + ) + .await + else { + return None; + }; + + let tdlib_rs::enums::Message::Message(original_msg) = original_msg_enum; + let sender_name = extract_sender_name(&original_msg, client_id).await; + let text: String = extract_content_text(&original_msg) + .chars() + .take(50) + .collect(); + Some((sender_name, text)) + }) + .await + .ok() + .flatten(); + + if let Some((sender_name, text)) = result { + let _ = tx.send(ChatInitEvent::ReplyInfoLoaded { + chat_id, + message_id, + sender_name, + text, + }); + } + }); + } + } // Авто-загрузка фото — неблокирующая фоновая задача (до 5 фото параллельно) #[cfg(feature = "images")] @@ -145,8 +197,55 @@ pub async fn process_pending_chat_init(app: &mut App, chat_ } } } +} - app.needs_redraw = true; +/// Применяет готовые результаты фоновой инициализации чата. +pub fn process_chat_init_events(app: &mut App) { + let mut events = Vec::new(); + let mut disconnected = false; + + if let Some(rx) = app.chat_init_rx.as_mut() { + loop { + match rx.try_recv() { + Ok(event) => events.push(event), + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + disconnected = true; + break; + } + } + } + } + + if disconnected { + app.chat_init_rx = None; + } + + for event in events { + match event { + ChatInitEvent::ReplyInfoLoaded { chat_id, message_id, sender_name, text } => { + if app.td_client.current_chat_id() != Some(chat_id) { + continue; + } + + let mut changed = false; + for msg in app.td_client.current_chat_messages_mut() { + let Some(reply) = msg.interactions.reply_to.as_mut() else { + continue; + }; + if reply.message_id == message_id { + reply.sender_name = sender_name.clone(); + reply.text = text.clone(); + changed = true; + } + } + + if changed { + app.needs_redraw = true; + } + } + } + } } /// Подгружает старые сообщения если скролл близко к верху diff --git a/src/input/handlers/mod.rs b/src/input/handlers/mod.rs index f94e35d..cdaa9e0 100644 --- a/src/input/handlers/mod.rs +++ b/src/input/handlers/mod.rs @@ -21,7 +21,10 @@ pub mod modal; pub mod profile; pub mod search; -pub use chat_loader::{load_older_messages_if_needed, open_chat_and_load_data, process_pending_chat_init}; +pub use chat_loader::{ + load_older_messages_if_needed, open_chat_and_load_data, process_chat_init_events, + process_pending_chat_init, +}; pub use clipboard::*; pub use global::*; pub use profile::get_available_actions_count; diff --git a/src/input/handlers/modal.rs b/src/input/handlers/modal.rs index 6820250..e40a697 100644 --- a/src/input/handlers/modal.rs +++ b/src/input/handlers/modal.rs @@ -231,7 +231,7 @@ pub async fn handle_profile_mode( } } -/// Обработка Ctrl+U для открытия профиля чата/пользователя +/// Обработка Ctrl+I для открытия профиля чата/пользователя /// /// Загружает информацию о профиле и переключает в режим просмотра профиля pub async fn handle_profile_open(app: &mut App) { diff --git a/src/main.rs b/src/main.rs index a223ff7..90ba62b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,8 +28,8 @@ use tdlib_rs::enums::Update; use app::{App, AppScreen}; use constants::{POLL_TIMEOUT_MS, SHUTDOWN_TIMEOUT_SECS}; +use input::handlers::{process_chat_init_events, process_pending_chat_init}; use input::{handle_auth_input, handle_main_input}; -use input::handlers::process_pending_chat_init; use tdlib::AuthState; use utils::{disable_tdlib_logs, with_timeout_ignore}; @@ -84,7 +84,9 @@ async fn main() -> Result<(), io::Error> { // Acquire per-account lock BEFORE raw mode (so error prints to normal terminal) let account_lock = accounts::acquire_lock( - account_arg.as_deref().unwrap_or(&accounts_config.default_account), + account_arg + .as_deref() + .unwrap_or(&accounts_config.default_account), ) .unwrap_or_else(|e| { eprintln!("Error: {}", e); @@ -166,16 +168,18 @@ async fn run_app( let should_stop = Arc::new(AtomicBool::new(false)); let should_stop_clone = should_stop.clone(); - // Канал для передачи updates из polling задачи в main loop - let (update_tx, mut update_rx) = tokio::sync::mpsc::unbounded_channel::(); + // Канал для передачи updates из polling задачи в main loop. + // client_id нужен при переключении аккаунтов: TDLib может ещё отдать + // updates от старого клиента после recreate_client(). + let (update_tx, mut update_rx) = tokio::sync::mpsc::unbounded_channel::<(i32, Update)>(); // Запускаем polling TDLib receive() в отдельной задаче let polling_handle = tokio::spawn(async move { while !should_stop_clone.load(Ordering::Relaxed) { // receive() с таймаутом 0.1 сек чтобы периодически проверять флаг let result = tokio::task::spawn_blocking(tdlib_rs::receive).await; - if let Ok(Some((update, _client_id))) = result { - if update_tx.send(update).is_err() { + if let Ok(Some((update, client_id))) = result { + if update_tx.send((client_id, update)).is_err() { break; // Канал закрыт, выходим } } @@ -185,9 +189,18 @@ async fn run_app( loop { // Обрабатываем updates от TDLib из канала (неблокирующе) let mut had_updates = false; - while let Ok(update) = update_rx.try_recv() { - app.td_client.handle_update(update); - had_updates = true; + let active_client_id = app.td_client.client_id(); + while let Ok((client_id, update)) = update_rx.try_recv() { + if client_id == active_client_id { + app.td_client.handle_update(update); + had_updates = true; + } else { + tracing::debug!( + "Ignoring TDLib update for inactive client_id={} (active={})", + client_id, + active_client_id + ); + } } // Помечаем UI как требующий перерисовки если были обновления @@ -195,6 +208,8 @@ async fn run_app( app.needs_redraw = true; } + process_chat_init_events(app); + // Обрабатываем результаты фоновой загрузки фото #[cfg(feature = "images")] { @@ -224,11 +239,8 @@ async fn run_app( .unwrap_or(false); if pending_matches { // Ищем путь из обновлённого состояния - let downloaded_path = app - .td_client - .current_chat_messages() - .iter() - .find_map(|m| { + let downloaded_path = + app.td_client.current_chat_messages().iter().find_map(|m| { m.photo_info().and_then(|p| { if p.file_id == file_id { if let PhotoDownloadState::Downloaded(ref path) = @@ -385,9 +397,13 @@ async fn run_app( } } - // Process pending chat initialization (reply info, pinned, photos) - if let Some(chat_id) = app.pending_chat_init.take() { - process_pending_chat_init(app, chat_id).await; + // Process pending chat initialization only after pending redraw is flushed. + // This guarantees the initial 50-message chat view is rendered before slower + // reply/photo initialization tasks start. + if !app.needs_redraw { + if let Some(chat_id) = app.pending_chat_init.take() { + process_pending_chat_init(app, chat_id); + } } // Check pending account switch @@ -410,17 +426,20 @@ async fn run_app( // 1. Stop playback app.stop_playback(); - // 2. Recreate client (closes old, creates new, inits TDLib params) + // 2. Drop queued updates from the old client before recreating TDLib. + while update_rx.try_recv().is_ok() {} + + // 3. Recreate client (closes old, creates new, inits TDLib params) if let Err(e) = app.td_client.recreate_client(new_db_path).await { app.error_message = Some(format!("Ошибка переключения: {}", e)); continue; } - // 3. Reset app state + // 4. Reset app state app.current_account_name = account_name.clone(); app.screen = AppScreen::Loading; - // 4. Persist selected account as default for next launch + // 5. Persist selected account as default for next launch let mut accounts_config = accounts::load_or_create(); accounts_config.default_account = account_name; if let Err(e) = accounts::save(&accounts_config) { @@ -438,6 +457,12 @@ async fn run_app( app.cursor_position = 0; app.message_scroll_offset = 0; app.pending_chat_init = None; + app.chat_init_rx = None; + #[cfg(feature = "images")] + { + app.photo_download_rx = None; + app.pending_image_open = None; + } app.account_switcher = None; app.needs_redraw = true; diff --git a/src/message_grouping.rs b/src/message_grouping.rs index 206ca83..bcc9732 100644 --- a/src/message_grouping.rs +++ b/src/message_grouping.rs @@ -8,7 +8,7 @@ use crate::utils::get_day; /// Элемент группированного списка сообщений #[derive(Debug, Clone)] -pub enum MessageGroup { +pub enum MessageGroup<'a> { /// Разделитель даты (день в формате timestamp) DateSeparator(i32), /// Заголовок отправителя (is_outgoing, sender_name) @@ -17,9 +17,9 @@ pub enum MessageGroup { sender_name: String, }, /// Сообщение - Message(Box), + Message(&'a MessageInfo), /// Альбом (группа фото с одинаковым media_album_id) - Album(Vec), + Album(Vec<&'a MessageInfo>), } /// Группирует сообщения по дате и отправителю @@ -63,14 +63,14 @@ pub enum MessageGroup { /// } /// } /// ``` -pub fn group_messages(messages: &[MessageInfo]) -> Vec { +pub fn group_messages(messages: &[MessageInfo]) -> Vec> { let mut result = Vec::new(); let mut last_day: Option = None; let mut last_sender: Option<(bool, String)> = None; // (is_outgoing, sender_name) - let mut album_acc: Vec = Vec::new(); + let mut album_acc: Vec<&MessageInfo> = Vec::new(); /// Сбрасывает аккумулятор альбома в результат - fn flush_album(acc: &mut Vec, result: &mut Vec) { + fn flush_album<'a>(acc: &mut Vec<&'a MessageInfo>, result: &mut Vec>) { if acc.is_empty() { return; } @@ -78,7 +78,7 @@ pub fn group_messages(messages: &[MessageInfo]) -> Vec { result.push(MessageGroup::Album(std::mem::take(acc))); } else { // Одно сообщение — не альбом - result.push(MessageGroup::Message(Box::new(acc.remove(0)))); + result.push(MessageGroup::Message(acc.remove(0))); } } @@ -120,24 +120,24 @@ pub fn group_messages(messages: &[MessageInfo]) -> Vec { if let Some(first) = album_acc.first() { if first.media_album_id() == album_id { // Тот же альбом — добавляем - album_acc.push(msg.clone()); + album_acc.push(msg); continue; } else { // Другой альбом — flush старый, начинаем новый flush_album(&mut album_acc, &mut result); - album_acc.push(msg.clone()); + album_acc.push(msg); continue; } } else { // Аккумулятор пуст — начинаем новый альбом - album_acc.push(msg.clone()); + album_acc.push(msg); continue; } } // Обычное сообщение (не альбом) — flush аккумулятор flush_album(&mut album_acc, &mut result); - result.push(MessageGroup::Message(Box::new(msg.clone()))); + result.push(MessageGroup::Message(msg)); } // Flush оставшийся аккумулятор diff --git a/src/tdlib/client.rs b/src/tdlib/client.rs index 0cdd34d..e4b88cc 100644 --- a/src/tdlib/client.rs +++ b/src/tdlib/client.rs @@ -238,13 +238,24 @@ impl TdClient { self.chat_manager.clear_stale_typing_status() } + fn last_read_outbox_message_id(&self, chat_id: ChatId) -> MessageId { + self.chats() + .iter() + .find(|chat| chat.id == chat_id) + .map(|chat| chat.last_read_outbox_message_id) + .unwrap_or(MessageId::new(0)) + } + // Делегирование к message_manager pub async fn get_chat_history( &mut self, chat_id: ChatId, limit: i32, ) -> Result, String> { - self.message_manager.get_chat_history(chat_id, limit).await + let last_read_outbox_message_id = self.last_read_outbox_message_id(chat_id); + self.message_manager + .get_chat_history(chat_id, limit, last_read_outbox_message_id) + .await } pub async fn load_older_messages( @@ -252,8 +263,9 @@ impl TdClient { chat_id: ChatId, from_message_id: MessageId, ) -> Result, String> { + let last_read_outbox_message_id = self.last_read_outbox_message_id(chat_id); self.message_manager - .load_older_messages(chat_id, from_message_id) + .load_older_messages(chat_id, from_message_id, last_read_outbox_message_id) .await } @@ -261,7 +273,10 @@ impl TdClient { &mut self, chat_id: ChatId, ) -> Result, String> { - self.message_manager.get_pinned_messages(chat_id).await + let last_read_outbox_message_id = self.last_read_outbox_message_id(chat_id); + self.message_manager + .get_pinned_messages(chat_id, last_read_outbox_message_id) + .await } pub async fn load_current_pinned_message(&mut self, chat_id: ChatId) { @@ -275,7 +290,10 @@ impl TdClient { chat_id: ChatId, query: &str, ) -> Result, String> { - self.message_manager.search_messages(chat_id, query).await + let last_read_outbox_message_id = self.last_read_outbox_message_id(chat_id); + self.message_manager + .search_messages(chat_id, query, last_read_outbox_message_id) + .await } pub async fn send_message( @@ -285,8 +303,15 @@ impl TdClient { reply_to_message_id: Option, reply_info: Option, ) -> Result { + let last_read_outbox_message_id = self.last_read_outbox_message_id(chat_id); self.message_manager - .send_message(chat_id, text, reply_to_message_id, reply_info) + .send_message( + chat_id, + text, + reply_to_message_id, + reply_info, + last_read_outbox_message_id, + ) .await } @@ -296,8 +321,9 @@ impl TdClient { message_id: MessageId, text: String, ) -> Result { + let last_read_outbox_message_id = self.last_read_outbox_message_id(chat_id); self.message_manager - .edit_message(chat_id, message_id, text) + .edit_message(chat_id, message_id, text, last_read_outbox_message_id) .await } diff --git a/src/tdlib/client_impl.rs b/src/tdlib/client_impl.rs index 8318199..aea63d4 100644 --- a/src/tdlib/client_impl.rs +++ b/src/tdlib/client_impl.rs @@ -94,9 +94,7 @@ impl TdClientTrait for TdClient { reply_to_message_id: Option, reply_info: Option, ) -> Result { - self.message_manager - .send_message(chat_id, text, reply_to_message_id, reply_info) - .await + TdClient::send_message(self, chat_id, text, reply_to_message_id, reply_info).await } async fn edit_message( @@ -105,9 +103,7 @@ impl TdClientTrait for TdClient { message_id: MessageId, new_text: String, ) -> Result { - self.message_manager - .edit_message(chat_id, message_id, new_text) - .await + TdClient::edit_message(self, chat_id, message_id, new_text).await } async fn delete_messages( diff --git a/src/tdlib/messages/convert.rs b/src/tdlib/messages/convert.rs index 0e4a4e6..4d4f514 100644 --- a/src/tdlib/messages/convert.rs +++ b/src/tdlib/messages/convert.rs @@ -10,7 +10,11 @@ use super::MessageManager; impl MessageManager { /// Конвертировать TdMessage в MessageInfo - pub(crate) async fn convert_message(&self, msg: &TdMessage) -> Option { + pub(crate) async fn convert_message( + &self, + msg: &TdMessage, + last_read_outbox_message_id: MessageId, + ) -> Option { use crate::tdlib::message_conversion::{ extract_content_text, extract_entities, extract_forward_info, extract_media_info, extract_reactions, extract_reply_info, extract_sender_name, @@ -39,7 +43,8 @@ impl MessageManager { builder = builder.incoming(); } - if !msg.contains_unread_mention { + let is_read = !msg.is_outgoing || msg.id <= last_read_outbox_message_id.as_i64(); + if is_read { builder = builder.read(); } else { builder = builder.unread(); @@ -117,7 +122,7 @@ impl MessageManager { }; let tdlib_rs::enums::Message::Message(original_msg) = original_msg_enum; - let Some(orig_info) = self.convert_message(&original_msg).await else { + let Some(orig_info) = self.convert_message(&original_msg, MessageId::new(0)).await else { return; }; diff --git a/src/tdlib/messages/operations.rs b/src/tdlib/messages/operations.rs index 651446f..7dd2635 100644 --- a/src/tdlib/messages/operations.rs +++ b/src/tdlib/messages/operations.rs @@ -41,6 +41,7 @@ impl MessageManager { &mut self, chat_id: ChatId, limit: i32, + last_read_outbox_message_id: MessageId, ) -> Result, String> { // ВАЖНО: Сначала открываем чат в TDLib // Это сообщает TDLib что пользователь открыл чат и нужно загрузить историю @@ -119,7 +120,8 @@ impl MessageManager { // Конвертируем сообщения (от новых к старым, потом реверсим) let mut chunk_messages = Vec::new(); for msg in messages_obj.messages.iter().flatten() { - if let Some(info) = self.convert_message(msg).await { + if let Some(info) = self.convert_message(msg, last_read_outbox_message_id).await + { chunk_messages.push(info); } } @@ -192,6 +194,7 @@ impl MessageManager { &mut self, chat_id: ChatId, from_message_id: MessageId, + last_read_outbox_message_id: MessageId, ) -> Result, String> { let result = functions::get_chat_history( chat_id.as_i64(), @@ -207,7 +210,8 @@ impl MessageManager { Ok(tdlib_rs::enums::Messages::Messages(messages_obj)) => { let mut messages = Vec::new(); for msg in messages_obj.messages.iter().rev().flatten() { - if let Some(info) = self.convert_message(msg).await { + if let Some(info) = self.convert_message(msg, last_read_outbox_message_id).await + { messages.push(info); } } @@ -239,6 +243,7 @@ impl MessageManager { pub async fn get_pinned_messages( &mut self, chat_id: ChatId, + last_read_outbox_message_id: MessageId, ) -> Result, String> { let result = functions::search_chat_messages( chat_id.as_i64(), @@ -258,7 +263,8 @@ impl MessageManager { Ok(tdlib_rs::enums::FoundChatMessages::FoundChatMessages(messages_obj)) => { let mut pinned_messages = Vec::new(); for msg in messages_obj.messages.iter().rev() { - if let Some(info) = self.convert_message(msg).await { + if let Some(info) = self.convert_message(msg, last_read_outbox_message_id).await + { pinned_messages.push(info); } } @@ -307,6 +313,7 @@ impl MessageManager { &self, chat_id: ChatId, query: &str, + last_read_outbox_message_id: MessageId, ) -> Result, String> { let result = functions::search_chat_messages( chat_id.as_i64(), @@ -326,7 +333,8 @@ impl MessageManager { Ok(tdlib_rs::enums::FoundChatMessages::FoundChatMessages(messages_obj)) => { let mut search_results = Vec::new(); for msg in messages_obj.messages.iter().rev() { - if let Some(info) = self.convert_message(msg).await { + if let Some(info) = self.convert_message(msg, last_read_outbox_message_id).await + { search_results.push(info); } } @@ -377,6 +385,7 @@ impl MessageManager { text: String, reply_to_message_id: Option, reply_info: Option, + last_read_outbox_message_id: MessageId, ) -> Result { // Парсим markdown в тексте let formatted_text = match functions::parse_text_entities( @@ -419,7 +428,7 @@ impl MessageManager { match result { Ok(tdlib_rs::enums::Message::Message(msg)) => { let mut msg_info = self - .convert_message(&msg) + .convert_message(&msg, last_read_outbox_message_id) .await .ok_or_else(|| "Не удалось конвертировать сообщение".to_string())?; @@ -451,6 +460,7 @@ impl MessageManager { chat_id: ChatId, message_id: MessageId, text: String, + last_read_outbox_message_id: MessageId, ) -> Result { let formatted_text = match functions::parse_text_entities( text.clone(), @@ -481,7 +491,7 @@ impl MessageManager { match result { Ok(tdlib_rs::enums::Message::Message(msg)) => self - .convert_message(&msg) + .convert_message(&msg, last_read_outbox_message_id) .await .ok_or_else(|| "Не удалось конвертировать отредактированное сообщение".to_string()), Err(e) => Err(format!("Ошибка редактирования: {:?}", e)), diff --git a/src/tdlib/mod.rs b/src/tdlib/mod.rs index 842ebc9..7d32549 100644 --- a/src/tdlib/mod.rs +++ b/src/tdlib/mod.rs @@ -4,7 +4,7 @@ mod chat_helpers; // Chat management helpers pub mod chats; pub mod client; mod client_impl; // Private module for trait implementation -mod message_conversion; // Message conversion utilities (for messages.rs) +pub(crate) mod message_conversion; // Message conversion utilities (for messages.rs) mod message_converter; // Message conversion utilities (for client.rs) pub mod messages; pub mod reactions; diff --git a/src/ui/components/message_bubble.rs b/src/ui/components/message_bubble.rs index 3769914..8f77383 100644 --- a/src/ui/components/message_bubble.rs +++ b/src/ui/components/message_bubble.rs @@ -531,7 +531,7 @@ pub struct DeferredImageRender { /// Фото отображаются в сетке (до 3 в ряд), с общей подписью и timestamp. #[cfg(feature = "images")] pub fn render_album_bubble( - messages: &[MessageInfo], + messages: &[&MessageInfo], config: &Config, content_width: usize, selected_msg_id: Option, @@ -550,7 +550,7 @@ pub fn render_album_bubble( let selection_marker = if is_selected { "▶ " } else { " " }; // Фильтруем фото - let photos: Vec<&MessageInfo> = messages.iter().filter(|m| m.has_photo()).collect(); + let photos: Vec<&MessageInfo> = messages.iter().copied().filter(|m| m.has_photo()).collect(); let photo_count = photos.len(); if photo_count == 0 { diff --git a/src/ui/messages.rs b/src/ui/messages.rs index c48fda5..c3c872d 100644 --- a/src/ui/messages.rs +++ b/src/ui/messages.rs @@ -187,7 +187,8 @@ fn render_message_list(f: &mut Frame, area: Rect, app: &mut Ap let mut deferred_images: Vec = Vec::new(); // Используем message_grouping для группировки сообщений - let grouped = group_messages(&app.td_client.current_chat_messages()); + let current_messages = app.td_client.current_chat_messages(); + let grouped = group_messages(¤t_messages); let mut is_first_date = true; let mut is_first_sender = true; @@ -218,7 +219,7 @@ fn render_message_list(f: &mut Frame, area: Rect, app: &mut Ap // Рендерим сообщение let bubble_lines = components::render_message_bubble( - &msg, + msg, app.config(), content_width, selected_msg_id, diff --git a/tests/input_navigation.rs b/tests/input_navigation.rs index d0e9190..ba79910 100644 --- a/tests/input_navigation.rs +++ b/tests/input_navigation.rs @@ -83,7 +83,7 @@ async fn test_vim_navigation_in_chat_list() { assert_eq!(app.chat_list_state.selected(), Some(0)); } -/// Test: Русские клавиши о/р для навигации +/// Test: Русские клавиши о/л для навигации #[tokio::test] async fn test_russian_keyboard_navigation() { let mut app = TestAppBuilder::new() @@ -99,8 +99,8 @@ async fn test_russian_keyboard_navigation() { handle_main_input(&mut app, key(KeyCode::Char('о'))).await; assert_eq!(app.chat_list_state.selected(), Some(1)); - // р (русская k) - вверх - handle_main_input(&mut app, key(KeyCode::Char('р'))).await; + // л (русская k) - вверх + handle_main_input(&mut app, key(KeyCode::Char('л'))).await; assert_eq!(app.chat_list_state.selected(), Some(0)); } diff --git a/tests/snapshots/screens__main_screen_empty.snap b/tests/snapshots/screens__main_screen_empty.snap index a518f42..4af6c9d 100644 --- a/tests/snapshots/screens__main_screen_empty.snap +++ b/tests/snapshots/screens__main_screen_empty.snap @@ -25,4 +25,4 @@ expression: output ┌──────────────────────┐│ │ │ ││ │ └──────────────────────┘└──────────────────────────────────────────────────────┘ - Инициализация TDLib... + [default] Инициализация TDLib... diff --git a/tests/vim_mode.rs b/tests/vim_mode.rs index 3c45233..d6e28c0 100644 --- a/tests/vim_mode.rs +++ b/tests/vim_mode.rs @@ -435,9 +435,9 @@ async fn test_ctrl_w_deletes_word_with_spaces() { assert_eq!(app.cursor_position, 9); } -/// Ctrl+A → курсор в начало в Insert mode +/// Home → курсор в начало в Insert mode #[tokio::test] -async fn test_ctrl_a_moves_to_start_in_insert() { +async fn test_home_moves_to_start_in_insert() { let mut app = TestAppBuilder::new() .with_chats(vec![create_test_chat("Chat", 101)]) .selected_chat(101) @@ -446,7 +446,7 @@ async fn test_ctrl_a_moves_to_start_in_insert() { .build(); app.cursor_position = 11; - handle_main_input(&mut app, ctrl_key('a')).await; + handle_main_input(&mut app, key(KeyCode::Home)).await; assert_eq!(app.cursor_position, 0); }