diff --git a/docs/REFACTOR_PLAN.md b/docs/REFACTOR_PLAN.md index 1868f71..70fde3e 100644 --- a/docs/REFACTOR_PLAN.md +++ b/docs/REFACTOR_PLAN.md @@ -136,12 +136,12 @@ Target files: Steps: -- [ ] Split modal handlers by modal type and keep `modal.rs` as the dispatcher/module entry point. -- [ ] Split message UI rendering into header, pinned-message, and list rendering modules. -- [ ] Keep public function names stable until each split is covered by tests. -- [ ] Avoid mixing behavior changes with file movement. -- [ ] Run focused modal/navigation/message tests after each split. -- [ ] Run `cargo test --all-features` after the full split. +- [x] Split modal handlers by modal type and keep `modal.rs` as the dispatcher/module entry point. +- [x] Split message UI rendering into header, pinned-message, and list rendering modules. +- [x] Keep public function names stable until each split is covered by tests. +- [x] Avoid mixing behavior changes with file movement. +- [x] Run focused modal/navigation/message tests after each split. +- [x] Run `cargo test --all-features` after the full split. Acceptance criteria: diff --git a/src/input/handlers/modal.rs b/src/input/handlers/modal.rs index 4301bc1..a8601f6 100644 --- a/src/input/handlers/modal.rs +++ b/src/input/handlers/modal.rs @@ -1,405 +1,13 @@ -//! Modal dialog handlers -//! -//! Handles keyboard input for modal dialogs, including: -//! - Account switcher (global overlay) -//! - Delete confirmation -//! - Reaction picker (emoji selector) -//! - Pinned messages view -//! - Profile information modal +//! Modal dialog handlers. -use super::scroll_to_message; -use crate::app::methods::{modal::ModalMethods, navigation::NavigationMethods}; -use crate::app::{AccountSwitcherState, App}; -use crate::input::handlers::get_available_actions_count; -use crate::tdlib::TdClientTrait; -use crate::types::{ChatId, MessageId}; -use crate::utils::{modal_handler::handle_yes_no, with_timeout_msg}; -use crossterm::event::{KeyCode, KeyEvent}; -use std::time::Duration; +mod account; +mod delete; +mod pinned; +mod profile; +mod reactions; -/// Обработка ввода в модалке переключения аккаунтов -/// -/// **SelectAccount mode:** -/// - j/k (MoveUp/MoveDown) — навигация по списку -/// - Enter — выбор аккаунта или переход к добавлению -/// - a/ф — быстрое добавление аккаунта -/// - Esc — закрыть модалку -/// -/// **AddAccount mode:** -/// - Char input → ввод имени -/// - Backspace → удалить символ -/// - Enter → создать аккаунт -/// - Esc → назад к списку -pub async fn handle_account_switcher( - app: &mut App, - key: KeyEvent, - command: Option, -) { - let Some(state) = &app.account_switcher else { - return; - }; - - match state { - AccountSwitcherState::SelectAccount { .. } => { - match command { - Some(crate::config::Command::MoveUp) => { - app.account_switcher_select_prev(); - } - Some(crate::config::Command::MoveDown) => { - app.account_switcher_select_next(); - } - Some(crate::config::Command::SubmitMessage) => { - app.account_switcher_confirm(); - } - Some(crate::config::Command::Cancel) => { - app.close_account_switcher(); - } - _ => { - // Raw key check for 'a'/'ф' shortcut - match key.code { - KeyCode::Char('a') | KeyCode::Char('ф') => { - app.account_switcher_start_add(); - } - _ => {} - } - } - } - } - AccountSwitcherState::AddAccount { .. } => match key.code { - KeyCode::Esc => { - app.account_switcher_back(); - } - KeyCode::Enter => { - app.account_switcher_confirm_add(); - } - KeyCode::Backspace => { - if let Some(AccountSwitcherState::AddAccount { - name_input, - cursor_position, - error, - }) = &mut app.account_switcher - { - if *cursor_position > 0 { - let mut chars: Vec = name_input.chars().collect(); - chars.remove(*cursor_position - 1); - *name_input = chars.into_iter().collect(); - *cursor_position -= 1; - *error = None; - } - } - } - KeyCode::Char(c) => { - if let Some(AccountSwitcherState::AddAccount { - name_input, - cursor_position, - error, - }) = &mut app.account_switcher - { - let mut chars: Vec = name_input.chars().collect(); - chars.insert(*cursor_position, c); - *name_input = chars.into_iter().collect(); - *cursor_position += 1; - *error = None; - } - } - _ => {} - }, - } -} - -/// Обработка режима профиля пользователя/чата -/// -/// Обрабатывает: -/// - Модалку подтверждения выхода из группы (двухшаговая) -/// - Навигацию по действиям профиля (Up/Down) -/// - Выполнение выбранного действия (Enter): открыть в браузере, скопировать ID, покинуть группу -/// - Выход из режима профиля (Esc) -pub async fn handle_profile_mode( - app: &mut App, - key: KeyEvent, - command: Option, -) { - // Обработка подтверждения выхода из группы - let confirmation_step = app.get_leave_group_confirmation_step(); - if confirmation_step > 0 { - match handle_yes_no(key.code) { - Some(true) => { - // Подтверждение - if confirmation_step == 1 { - // Первое подтверждение - показываем второе - app.show_leave_group_final_confirmation(); - } else if confirmation_step == 2 { - // Второе подтверждение - выходим из группы - if let Some(chat_id) = app.selected_chat_id { - let leave_result = app.td_client.leave_chat(chat_id).await; - match leave_result { - Ok(_) => { - app.status_message = Some("Вы вышли из группы".to_string()); - app.exit_profile_mode(); - app.close_chat(); - } - Err(e) => { - app.error_message = Some(e); - app.cancel_leave_group(); - } - } - } - } - } - Some(false) => { - // Отмена - app.cancel_leave_group(); - } - None => { - // Другая клавиша - игнорируем - } - } - return; - } - - // Обычная навигация по профилю - match command { - Some(crate::config::Command::Cancel) => { - app.exit_profile_mode(); - } - Some(crate::config::Command::MoveUp) => { - app.select_previous_profile_action(); - } - Some(crate::config::Command::MoveDown) => { - if let Some(profile) = app.get_profile_info() { - let max_actions = get_available_actions_count(profile); - app.select_next_profile_action(max_actions); - } - } - Some(crate::config::Command::SubmitMessage) => { - // Выполнить выбранное действие - let Some(profile) = app.get_profile_info() else { - return; - }; - - let actions = get_available_actions_count(profile); - let action_index = app.get_selected_profile_action().unwrap_or(0); - - // Guard: проверяем, что индекс действия валидный - if action_index >= actions { - return; - } - - // Определяем какое действие выбрано - let mut current_idx = 0; - - // Действие: Открыть в браузере - if let Some(username) = &profile.username { - if action_index == current_idx { - let url = format!("https://t.me/{}", username.trim_start_matches('@')); - #[cfg(feature = "url-open")] - { - match open::that(&url) { - Ok(_) => { - app.status_message = Some(format!("Открыто: {}", url)); - } - Err(e) => { - app.error_message = - Some(format!("Ошибка открытия браузера: {}", e)); - } - } - } - #[cfg(not(feature = "url-open"))] - { - app.error_message = Some( - "Открытие URL недоступно (требуется feature 'url-open')".to_string(), - ); - } - return; - } - current_idx += 1; - } - - // Действие: Скопировать ID - if action_index == current_idx { - app.status_message = Some(format!("ID скопирован: {}", profile.chat_id)); - return; - } - current_idx += 1; - - // Действие: Покинуть группу - if profile.is_group && action_index == current_idx { - app.show_leave_group_confirmation(); - } - } - _ => {} - } -} - -/// Обработка Ctrl+I для открытия профиля чата/пользователя -/// -/// Загружает информацию о профиле и переключает в режим просмотра профиля -pub async fn handle_profile_open(app: &mut App) { - let Some(chat_id) = app.selected_chat_id else { - return; - }; - - app.status_message = Some("Загрузка профиля...".to_string()); - match with_timeout_msg( - Duration::from_secs(5), - app.td_client.get_profile_info(chat_id), - "Таймаут загрузки профиля", - ) - .await - { - Ok(profile) => { - app.enter_profile_mode(profile); - app.status_message = None; - } - Err(e) => { - app.error_message = Some(e); - app.status_message = None; - } - } -} - -/// Обработка модалки подтверждения удаления сообщения -/// -/// Обрабатывает: -/// - Подтверждение удаления (Y/y/Д/д) -/// - Отмена удаления (N/n/Т/т) -/// - Удаление для себя или для всех (зависит от can_be_deleted_for_all_users) -pub async fn handle_delete_confirmation(app: &mut App, key: KeyEvent) { - match handle_yes_no(key.code) { - Some(true) => { - // Подтверждение удаления - if let Some(msg_id) = app.chat_state.selected_message_id() { - if let Some(chat_id) = app.get_selected_chat_id() { - // Находим сообщение для проверки can_be_deleted_for_all_users - let can_delete_for_all = app - .td_client - .current_chat_messages() - .iter() - .find(|m| m.id() == msg_id) - .map(|m| m.can_be_deleted_for_all_users()) - .unwrap_or(false); - - match with_timeout_msg( - Duration::from_secs(5), - app.td_client.delete_messages( - ChatId::new(chat_id), - vec![msg_id], - can_delete_for_all, - ), - "Таймаут удаления", - ) - .await - { - Ok(_) => { - // Удаляем из локального списка - app.td_client - .update_current_chat_messages(|messages| { - messages.retain(|m| m.id() != msg_id); - }); - // Сбрасываем состояние - app.chat_state = crate::app::ChatState::Normal; - } - Err(e) => { - app.error_message = Some(e); - } - } - } - } - // Закрываем модалку - app.chat_state = crate::app::ChatState::Normal; - } - Some(false) => { - // Отмена удаления - app.chat_state = crate::app::ChatState::Normal; - } - None => { - // Другая клавиша - игнорируем - } - } -} - -/// Обработка режима выбора реакции (emoji picker) -/// -/// Обрабатывает: -/// - Навигацию по сетке реакций: Left/Right, Up/Down (сетка 8x6) -/// - Добавление/удаление реакции (Enter) -/// - Выход из режима (Esc) -pub async fn handle_reaction_picker_mode( - app: &mut App, - _key: KeyEvent, - command: Option, -) { - match command { - Some(crate::config::Command::MoveLeft) => { - app.select_previous_reaction(); - app.needs_redraw = true; - } - Some(crate::config::Command::MoveRight) => { - app.select_next_reaction(); - app.needs_redraw = true; - } - Some(crate::config::Command::MoveUp) => { - if let crate::app::ChatState::ReactionPicker { selected_index, .. } = - &mut app.chat_state - { - if *selected_index >= 8 { - *selected_index = selected_index.saturating_sub(8); - app.needs_redraw = true; - } - } - } - Some(crate::config::Command::MoveDown) => { - if let crate::app::ChatState::ReactionPicker { - selected_index, - available_reactions, - .. - } = &mut app.chat_state - { - let new_index = *selected_index + 8; - if new_index < available_reactions.len() { - *selected_index = new_index; - app.needs_redraw = true; - } - } - } - Some(crate::config::Command::SubmitMessage) => { - super::chat::send_reaction(app).await; - } - Some(crate::config::Command::Cancel) => { - app.exit_reaction_picker_mode(); - app.needs_redraw = true; - } - _ => {} - } -} - -/// Обработка режима просмотра закреплённых сообщений -/// -/// Обрабатывает: -/// - Навигацию по закреплённым сообщениям (Up/Down) -/// - Переход к сообщению в истории (Enter) -/// - Выход из режима (Esc) -pub async fn handle_pinned_mode( - app: &mut App, - _key: KeyEvent, - command: Option, -) { - match command { - Some(crate::config::Command::Cancel) => { - app.exit_pinned_mode(); - } - Some(crate::config::Command::MoveUp) => { - app.select_previous_pinned(); - } - Some(crate::config::Command::MoveDown) => { - app.select_next_pinned(); - } - Some(crate::config::Command::SubmitMessage) => { - if let Some(msg_id) = app.get_selected_pinned_id() { - scroll_to_message(app, MessageId::new(msg_id)); - app.exit_pinned_mode(); - } - } - _ => {} - } -} +pub use account::handle_account_switcher; +pub use delete::handle_delete_confirmation; +pub use pinned::handle_pinned_mode; +pub use profile::{handle_profile_mode, handle_profile_open}; +pub use reactions::handle_reaction_picker_mode; diff --git a/src/input/handlers/modal/account.rs b/src/input/handlers/modal/account.rs new file mode 100644 index 0000000..0a0519a --- /dev/null +++ b/src/input/handlers/modal/account.rs @@ -0,0 +1,76 @@ +use crate::app::{AccountSwitcherState, App}; +use crate::tdlib::TdClientTrait; +use crossterm::event::{KeyCode, KeyEvent}; + +/// Обработка ввода в модалке переключения аккаунтов. +pub async fn handle_account_switcher( + app: &mut App, + key: KeyEvent, + command: Option, +) { + let Some(state) = &app.account_switcher else { + return; + }; + + match state { + AccountSwitcherState::SelectAccount { .. } => match command { + Some(crate::config::Command::MoveUp) => { + app.account_switcher_select_prev(); + } + Some(crate::config::Command::MoveDown) => { + app.account_switcher_select_next(); + } + Some(crate::config::Command::SubmitMessage) => { + app.account_switcher_confirm(); + } + Some(crate::config::Command::Cancel) => { + app.close_account_switcher(); + } + _ => match key.code { + KeyCode::Char('a') | KeyCode::Char('ф') => { + app.account_switcher_start_add(); + } + _ => {} + }, + }, + AccountSwitcherState::AddAccount { .. } => match key.code { + KeyCode::Esc => { + app.account_switcher_back(); + } + KeyCode::Enter => { + app.account_switcher_confirm_add(); + } + KeyCode::Backspace => { + if let Some(AccountSwitcherState::AddAccount { + name_input, + cursor_position, + error, + }) = &mut app.account_switcher + { + if *cursor_position > 0 { + let mut chars: Vec = name_input.chars().collect(); + chars.remove(*cursor_position - 1); + *name_input = chars.into_iter().collect(); + *cursor_position -= 1; + *error = None; + } + } + } + KeyCode::Char(c) => { + if let Some(AccountSwitcherState::AddAccount { + name_input, + cursor_position, + error, + }) = &mut app.account_switcher + { + let mut chars: Vec = name_input.chars().collect(); + chars.insert(*cursor_position, c); + *name_input = chars.into_iter().collect(); + *cursor_position += 1; + *error = None; + } + } + _ => {} + }, + } +} diff --git a/src/input/handlers/modal/delete.rs b/src/input/handlers/modal/delete.rs new file mode 100644 index 0000000..8162de0 --- /dev/null +++ b/src/input/handlers/modal/delete.rs @@ -0,0 +1,52 @@ +use crate::app::{App, ChatState}; +use crate::tdlib::TdClientTrait; +use crate::types::ChatId; +use crate::utils::{modal_handler::handle_yes_no, with_timeout_msg}; +use crossterm::event::KeyEvent; +use std::time::Duration; + +/// Обработка модалки подтверждения удаления сообщения. +pub async fn handle_delete_confirmation(app: &mut App, key: KeyEvent) { + match handle_yes_no(key.code) { + Some(true) => { + if let Some(msg_id) = app.chat_state.selected_message_id() { + if let Some(chat_id) = app.get_selected_chat_id() { + let can_delete_for_all = app + .td_client + .current_chat_messages() + .iter() + .find(|m| m.id() == msg_id) + .map(|m| m.can_be_deleted_for_all_users()) + .unwrap_or(false); + + match with_timeout_msg( + Duration::from_secs(5), + app.td_client.delete_messages( + ChatId::new(chat_id), + vec![msg_id], + can_delete_for_all, + ), + "Таймаут удаления", + ) + .await + { + Ok(_) => { + app.td_client.update_current_chat_messages(|messages| { + messages.retain(|m| m.id() != msg_id); + }); + app.chat_state = ChatState::Normal; + } + Err(e) => { + app.error_message = Some(e); + } + } + } + } + app.chat_state = ChatState::Normal; + } + Some(false) => { + app.chat_state = ChatState::Normal; + } + None => {} + } +} diff --git a/src/input/handlers/modal/pinned.rs b/src/input/handlers/modal/pinned.rs new file mode 100644 index 0000000..0bbc461 --- /dev/null +++ b/src/input/handlers/modal/pinned.rs @@ -0,0 +1,32 @@ +use crate::app::methods::modal::ModalMethods; +use crate::app::App; +use crate::input::handlers::scroll_to_message; +use crate::tdlib::TdClientTrait; +use crate::types::MessageId; +use crossterm::event::KeyEvent; + +/// Обработка режима просмотра закреплённых сообщений. +pub async fn handle_pinned_mode( + app: &mut App, + _key: KeyEvent, + command: Option, +) { + match command { + Some(crate::config::Command::Cancel) => { + app.exit_pinned_mode(); + } + Some(crate::config::Command::MoveUp) => { + app.select_previous_pinned(); + } + Some(crate::config::Command::MoveDown) => { + app.select_next_pinned(); + } + Some(crate::config::Command::SubmitMessage) => { + if let Some(msg_id) = app.get_selected_pinned_id() { + scroll_to_message(app, MessageId::new(msg_id)); + app.exit_pinned_mode(); + } + } + _ => {} + } +} diff --git a/src/input/handlers/modal/profile.rs b/src/input/handlers/modal/profile.rs new file mode 100644 index 0000000..901c634 --- /dev/null +++ b/src/input/handlers/modal/profile.rs @@ -0,0 +1,136 @@ +use crate::app::methods::modal::ModalMethods; +use crate::app::methods::navigation::NavigationMethods; +use crate::app::App; +use crate::input::handlers::get_available_actions_count; +use crate::tdlib::TdClientTrait; +use crate::utils::{modal_handler::handle_yes_no, with_timeout_msg}; +use crossterm::event::KeyEvent; +use std::time::Duration; + +/// Обработка режима профиля пользователя/чата. +pub async fn handle_profile_mode( + app: &mut App, + key: KeyEvent, + command: Option, +) { + let confirmation_step = app.get_leave_group_confirmation_step(); + if confirmation_step > 0 { + match handle_yes_no(key.code) { + Some(true) => { + if confirmation_step == 1 { + app.show_leave_group_final_confirmation(); + } else if confirmation_step == 2 { + if let Some(chat_id) = app.selected_chat_id { + let leave_result = app.td_client.leave_chat(chat_id).await; + match leave_result { + Ok(_) => { + app.status_message = Some("Вы вышли из группы".to_string()); + app.exit_profile_mode(); + app.close_chat(); + } + Err(e) => { + app.error_message = Some(e); + app.cancel_leave_group(); + } + } + } + } + } + Some(false) => { + app.cancel_leave_group(); + } + None => {} + } + return; + } + + match command { + Some(crate::config::Command::Cancel) => { + app.exit_profile_mode(); + } + Some(crate::config::Command::MoveUp) => { + app.select_previous_profile_action(); + } + Some(crate::config::Command::MoveDown) => { + if let Some(profile) = app.get_profile_info() { + let max_actions = get_available_actions_count(profile); + app.select_next_profile_action(max_actions); + } + } + Some(crate::config::Command::SubmitMessage) => { + let Some(profile) = app.get_profile_info() else { + return; + }; + + let actions = get_available_actions_count(profile); + let action_index = app.get_selected_profile_action().unwrap_or(0); + if action_index >= actions { + return; + } + + let mut current_idx = 0; + + if let Some(username) = &profile.username { + if action_index == current_idx { + let url = format!("https://t.me/{}", username.trim_start_matches('@')); + #[cfg(feature = "url-open")] + { + match open::that(&url) { + Ok(_) => { + app.status_message = Some(format!("Открыто: {}", url)); + } + Err(e) => { + app.error_message = + Some(format!("Ошибка открытия браузера: {}", e)); + } + } + } + #[cfg(not(feature = "url-open"))] + { + app.error_message = Some( + "Открытие URL недоступно (требуется feature 'url-open')".to_string(), + ); + } + return; + } + current_idx += 1; + } + + if action_index == current_idx { + app.status_message = Some(format!("ID скопирован: {}", profile.chat_id)); + return; + } + current_idx += 1; + + if profile.is_group && action_index == current_idx { + app.show_leave_group_confirmation(); + } + } + _ => {} + } +} + +/// Обработка Ctrl+I для открытия профиля чата/пользователя. +pub async fn handle_profile_open(app: &mut App) { + let Some(chat_id) = app.selected_chat_id else { + return; + }; + + app.status_message = Some("Загрузка профиля...".to_string()); + match with_timeout_msg( + Duration::from_secs(5), + app.td_client.get_profile_info(chat_id), + "Таймаут загрузки профиля", + ) + .await + { + Ok(profile) => { + app.enter_profile_mode(profile); + app.status_message = None; + } + Err(e) => { + app.error_message = Some(e); + app.status_message = None; + } + } +} diff --git a/src/input/handlers/modal/reactions.rs b/src/input/handlers/modal/reactions.rs new file mode 100644 index 0000000..95cc5cc --- /dev/null +++ b/src/input/handlers/modal/reactions.rs @@ -0,0 +1,54 @@ +use crate::app::methods::modal::ModalMethods; +use crate::app::App; +use crate::tdlib::TdClientTrait; +use crossterm::event::KeyEvent; + +/// Обработка режима выбора реакции (emoji picker). +pub async fn handle_reaction_picker_mode( + app: &mut App, + _key: KeyEvent, + command: Option, +) { + match command { + Some(crate::config::Command::MoveLeft) => { + app.select_previous_reaction(); + app.needs_redraw = true; + } + Some(crate::config::Command::MoveRight) => { + app.select_next_reaction(); + app.needs_redraw = true; + } + Some(crate::config::Command::MoveUp) => { + if let crate::app::ChatState::ReactionPicker { selected_index, .. } = + &mut app.chat_state + { + if *selected_index >= 8 { + *selected_index = selected_index.saturating_sub(8); + app.needs_redraw = true; + } + } + } + Some(crate::config::Command::MoveDown) => { + if let crate::app::ChatState::ReactionPicker { + selected_index, + available_reactions, + .. + } = &mut app.chat_state + { + let new_index = *selected_index + 8; + if new_index < available_reactions.len() { + *selected_index = new_index; + app.needs_redraw = true; + } + } + } + Some(crate::config::Command::SubmitMessage) => { + crate::input::handlers::chat::send_reaction(app).await; + } + Some(crate::config::Command::Cancel) => { + app.exit_reaction_picker_mode(); + app.needs_redraw = true; + } + _ => {} + } +} diff --git a/src/ui/messages.rs b/src/ui/messages.rs index 0d094bc..fc27588 100644 --- a/src/ui/messages.rs +++ b/src/ui/messages.rs @@ -1,408 +1,33 @@ //! 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::methods::{messages::MessageMethods, modal::ModalMethods, search::SearchMethods}; +mod header; +mod list; +mod pinned; + +use crate::app::methods::{modal::ModalMethods, search::SearchMethods}; use crate::app::App; -use crate::message_grouping::{group_messages, MessageGroup}; use crate::tdlib::TdClientTrait; -use crate::ui::components; use crate::ui::{compose_bar, modals}; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span}, + style::{Color, Style}, widgets::{Block, Borders, Paragraph}, Frame, }; -/// Рендерит заголовок чата с typing status -fn render_chat_header( - f: &mut Frame, - area: Rect, - app: &App, - chat: &crate::tdlib::ChatInfo, -) { - let typing_action = app - .td_client - .typing_status() - .as_ref() - .map(|(_, action, _)| action.clone()); +use header::render_chat_header; +use list::render_message_list; +use pinned::render_pinned_bar; - let header_line = if let Some(action) = typing_action { - // Показываем typing status: "👤 Имя @username печатает..." - let mut spans = vec![Span::styled( - format!("👤 {}", chat.title), - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )]; - if let Some(username) = &chat.username { - spans.push(Span::styled(format!(" {}", username), Style::default().fg(Color::Gray))); - } - spans.push(Span::styled( - format!(" {}", action), - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::ITALIC), - )); - Line::from(spans) - } else { - // Показываем username - let header_text = match &chat.username { - Some(username) => format!("👤 {} {}", chat.title, username), - None => format!("👤 {}", chat.title), - }; - Line::from(Span::styled( - header_text, - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )) - }; - - let header = Paragraph::new(header_line).block(Block::default().borders(Borders::ALL)); - f.render_widget(header, area); -} - -/// Рендерит pinned bar с закреплённым сообщением -fn render_pinned_bar(f: &mut Frame, area: Rect, app: &App) { - let Some(pinned_msg) = app.td_client.current_pinned_message() else { - return; - }; - - let pinned_preview: String = pinned_msg.text().chars().take(40).collect(); - let ellipsis = if pinned_msg.text().chars().count() > 40 { - "..." - } else { - "" - }; - let pinned_datetime = crate::utils::format_datetime(pinned_msg.date()); - let pinned_text = format!("📌 {} {}{}", pinned_datetime, pinned_preview, ellipsis); - let pinned_hint = "Ctrl+P"; - - let pinned_bar_width = area.width as usize; - let text_len = pinned_text.chars().count(); - let hint_len = pinned_hint.chars().count(); - let padding = pinned_bar_width.saturating_sub(text_len + hint_len + 2); - - let pinned_line = Line::from(vec![ - Span::styled(pinned_text, Style::default().fg(Color::Magenta)), - Span::raw(" ".repeat(padding)), - Span::styled(pinned_hint, Style::default().fg(Color::Gray)), - ]); - let pinned_bar = Paragraph::new(pinned_line).style(Style::default().bg(Color::Rgb(40, 20, 40))); - f.render_widget(pinned_bar, area); -} - -/// Информация о строке после переноса: текст и позиция в оригинале -pub(super) struct WrappedLine { - pub text: String, -} - -/// Разбивает текст на строки с учётом максимальной ширины -/// (используется только для search/pinned режимов, основной рендеринг через message_bubble) -pub(super) fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec { - if max_width == 0 { - return vec![WrappedLine { text: text.to_string() }]; - } - - let mut result = Vec::new(); - let mut current_line = String::new(); - let mut current_width = 0; - - let chars: Vec = text.chars().collect(); - let mut word_start = 0; - let mut in_word = false; - - for (i, ch) in chars.iter().enumerate() { - if ch.is_whitespace() { - if in_word { - let word: String = chars[word_start..i].iter().collect(); - let word_width = word.chars().count(); - - if current_width == 0 { - current_line = word; - current_width = word_width; - } else if current_width + 1 + word_width <= max_width { - current_line.push(' '); - current_line.push_str(&word); - current_width += 1 + word_width; - } else { - result.push(WrappedLine { text: current_line }); - current_line = word; - current_width = word_width; - } - in_word = false; - } - } else if !in_word { - word_start = i; - in_word = true; - } - } - - if in_word { - let word: String = chars[word_start..].iter().collect(); - let word_width = word.chars().count(); - - if current_width == 0 { - current_line = word; - } else if current_width + 1 + word_width <= max_width { - current_line.push(' '); - current_line.push_str(&word); - } else { - result.push(WrappedLine { text: current_line }); - current_line = word; - } - } - - if !current_line.is_empty() { - result.push(WrappedLine { text: current_line }); - } - - if result.is_empty() { - result.push(WrappedLine { text: String::new() }); - } - - result -} -/// Рендерит список сообщений с группировкой по дате/отправителю и автоскроллом -fn render_message_list(f: &mut Frame, area: Rect, app: &mut App) { - let content_width = area.width.saturating_sub(2) as usize; - - // Messages с группировкой по дате и отправителю - let mut lines: Vec = Vec::new(); - - // ID выбранного сообщения для подсветки - let selected_msg_id = app.get_selected_message().map(|m| m.id()); - // Номер строки, где начинается выбранное сообщение (для автоскролла) - let mut selected_msg_line: Option = None; - - // ОПТИМИЗАЦИЯ: Убрали массовый preloading всех изображений. - // Теперь загружаем только видимые изображения во втором проходе (см. ниже). - - // Собираем информацию о развёрнутых изображениях (для второго прохода) - #[cfg(feature = "images")] - let mut deferred_images: Vec = Vec::new(); - - // Используем message_grouping для группировки сообщений - 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; - - for group in grouped { - match group { - MessageGroup::DateSeparator(date) => { - // Рендерим разделитель даты - lines.extend(components::render_date_separator( - date, - content_width, - is_first_date, - )); - is_first_date = false; - is_first_sender = true; // Сбрасываем счётчик заголовков после даты - } - MessageGroup::SenderHeader { is_outgoing, sender_name } => { - // Рендерим заголовок отправителя - lines.extend(components::render_sender_header( - is_outgoing, - &sender_name, - content_width, - is_first_sender, - )); - is_first_sender = false; - } - MessageGroup::Message(msg) => { - // Запоминаем строку начала выбранного сообщения - let is_selected = selected_msg_id == Some(msg.id()); - if is_selected { - selected_msg_line = Some(lines.len()); - } - - // Рендерим сообщение - let bubble_lines = components::render_message_bubble( - msg, - app.config(), - content_width, - selected_msg_id, - app.playback_state.as_ref(), - ); - - // Собираем deferred image renders для всех загруженных фото - #[cfg(feature = "images")] - if let Some(photo) = msg.photo_info() { - if let crate::tdlib::PhotoDownloadState::Downloaded(path) = - &photo.download_state - { - let inline_width = - content_width.min(crate::constants::INLINE_IMAGE_MAX_WIDTH); - let img_height = components::calculate_image_height( - photo.width, - photo.height, - inline_width, - ); - let img_width = inline_width as u16; - let bubble_len = bubble_lines.len(); - let placeholder_start = lines.len() + bubble_len - img_height as usize; - - deferred_images.push(components::DeferredImageRender { - message_id: msg.id(), - photo_path: path.clone(), - line_offset: placeholder_start, - x_offset: 0, - width: img_width, - height: img_height, - }); - } - } - - lines.extend(bubble_lines); - } - MessageGroup::Album(album_messages) => { - #[cfg(feature = "images")] - { - let is_selected = album_messages - .iter() - .any(|m| selected_msg_id == Some(m.id())); - if is_selected { - selected_msg_line = Some(lines.len()); - } - - let (bubble_lines, album_deferred) = components::render_album_bubble( - &album_messages, - app.config(), - content_width, - selected_msg_id, - ); - - for mut d in album_deferred { - d.line_offset += lines.len(); - deferred_images.push(d); - } - - lines.extend(bubble_lines); - } - #[cfg(not(feature = "images"))] - { - // Fallback: рендерим каждое сообщение отдельно - for msg in &album_messages { - let is_selected = selected_msg_id == Some(msg.id()); - if is_selected { - selected_msg_line = Some(lines.len()); - } - lines.extend(components::render_message_bubble( - msg, - app.config(), - content_width, - selected_msg_id, - app.playback_state.as_ref(), - )); - } - } - } - } - } - - if lines.is_empty() { - lines.push(Line::from(Span::styled("Нет сообщений", Style::default().fg(Color::Gray)))); - } - - // Вычисляем скролл с учётом пользовательского offset - let visible_height = area.height.saturating_sub(2) as usize; - let total_lines = lines.len(); - - // Базовый скролл (показываем последние сообщения) - let base_scroll = total_lines.saturating_sub(visible_height); - - // Если выбрано сообщение, автоскроллим к нему - let scroll_offset = if app.is_selecting_message() { - if let Some(selected_line) = selected_msg_line { - // Вычисляем нужный скролл, чтобы выбранное сообщение было видно - if selected_line < visible_height / 2 { - // Сообщение в начале — скроллим к началу - 0 - } else if selected_line > total_lines.saturating_sub(visible_height / 2) { - // Сообщение в конце — скроллим к концу - base_scroll - } else { - // Центрируем выбранное сообщение - selected_line.saturating_sub(visible_height / 2) - } - } else { - base_scroll.saturating_sub(app.message_scroll_offset) - } - } else { - base_scroll.saturating_sub(app.message_scroll_offset) - } as u16; - - let messages_widget = Paragraph::new(lines) - .block(Block::default().borders(Borders::ALL)) - .scroll((scroll_offset, 0)); - f.render_widget(messages_widget, area); - - // Второй проход: рендерим изображения поверх placeholder-ов - #[cfg(feature = "images")] - { - use ratatui_image::StatefulImage; - - // THROTTLING: Рендерим изображения максимум 15 FPS (каждые 66ms) - let should_render_images = app - .last_image_render_time - .map(|t| t.elapsed() > std::time::Duration::from_millis(66)) - .unwrap_or(true); - - if !deferred_images.is_empty() && should_render_images { - let content_x = area.x + 1; - let content_y = area.y + 1; - - for d in &deferred_images { - let y_in_content = d.line_offset as i32 - scroll_offset as i32; - - // Пропускаем изображения, которые полностью за пределами видимости - if y_in_content < 0 || y_in_content as usize >= visible_height { - continue; - } - - let img_y = content_y + y_in_content as u16; - let remaining_height = (content_y + visible_height as u16).saturating_sub(img_y); - - // ВАЖНО: Не рендерим частично видимые изображения (убирает сжатие и мигание) - if d.height > remaining_height { - continue; - } - - // Рендерим с ПОЛНОЙ высотой (не сжимаем) - let img_rect = Rect::new(content_x + d.x_offset, img_y, d.width, d.height); - - // ОПТИМИЗАЦИЯ: Загружаем только видимые изображения (не все сразу) - // Используем inline_renderer с Halfblocks для скорости - if let Some(renderer) = &mut app.inline_image_renderer { - // Загружаем только если видимо (early return если уже в кеше) - let _ = renderer.load_image(d.message_id, &d.photo_path); - - if let Some(protocol) = renderer.get_protocol(&d.message_id) { - f.render_stateful_widget(StatefulImage::default(), img_rect, protocol); - } - } - } - - // Обновляем время последнего рендеринга (для throttling) - app.last_image_render_time = Some(std::time::Instant::now()); - } - } -} +pub(crate) use list::wrap_text_with_offsets; pub fn render(f: &mut Frame, area: Rect, app: &mut App) { - // Модальное окно просмотра изображения (приоритет выше всех) #[cfg(feature = "images")] if let Some(modal_state) = app.image_modal.clone() { modals::render_image_viewer(f, app, &modal_state); return; } - // Режим профиля if app.is_profile_mode() { if let Some(profile) = app.get_profile_info() { crate::ui::profile::render(f, area, app, profile); @@ -410,65 +35,52 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) { return; } - // Режим поиска по сообщениям if app.is_message_search_mode() { modals::render_search(f, area, app); return; } - // Режим просмотра закреплённых сообщений if app.is_pinned_mode() { modals::render_pinned(f, area, app); return; } if let Some(chat) = app.get_selected_chat().cloned() { - // Вычисляем динамическую высоту инпута на основе длины текста - let input_width = area.width.saturating_sub(4) as usize; // -2 для рамок, -2 для "> " + let input_width = area.width.saturating_sub(4) as usize; let input_lines: u16 = if input_width > 0 { - let len = app.message_input.chars().count() + 2; // +2 для "> " + let len = app.message_input.chars().count() + 2; ((len as f32 / input_width as f32).ceil() as u16).max(1) } else { 1 }; - // Минимум 3 строки (1 контент + 2 рамки), максимум 10 let input_height = (input_lines + 2).clamp(3, 10); - // Проверяем, есть ли закреплённое сообщение let has_pinned = app.td_client.current_pinned_message().is_some(); - let message_chunks = if has_pinned { Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), // Chat header - Constraint::Length(1), // Pinned bar - Constraint::Min(0), // Messages - Constraint::Length(input_height), // Input box (динамическая высота) + Constraint::Length(3), + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(input_height), ]) .split(area) } else { Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), // Chat header - Constraint::Length(0), // Pinned bar (hidden) - Constraint::Min(0), // Messages - Constraint::Length(input_height), // Input box (динамическая высота) + Constraint::Length(3), + Constraint::Length(0), + Constraint::Min(0), + Constraint::Length(input_height), ]) .split(area) }; - // Chat header с typing status render_chat_header(f, message_chunks[0], app, &chat); - - // Pinned bar (если есть закреплённое сообщение) render_pinned_bar(f, message_chunks[1], app); - - // Messages с группировкой по дате и отправителю render_message_list(f, message_chunks[2], app); - - // Input box с wrap для длинного текста и блочным курсором compose_bar::render(f, message_chunks[3], app); } else { let empty = Paragraph::new("Выберите чат") @@ -478,12 +90,10 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) { f.render_widget(empty, area); } - // Модалка подтверждения удаления if app.is_confirm_delete_shown() { modals::render_delete_confirm(f, area); } - // Модалка выбора реакции if let crate::app::ChatState::ReactionPicker { available_reactions, selected_index, .. } = &app.chat_state { diff --git a/src/ui/messages/header.rs b/src/ui/messages/header.rs new file mode 100644 index 0000000..6b8d221 --- /dev/null +++ b/src/ui/messages/header.rs @@ -0,0 +1,55 @@ +use crate::app::App; +use crate::tdlib::{ChatInfo, TdClientTrait}; +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; + +pub(super) fn render_chat_header( + f: &mut Frame, + area: Rect, + app: &App, + chat: &ChatInfo, +) { + let typing_action = app + .td_client + .typing_status() + .as_ref() + .map(|(_, action, _)| action.clone()); + + let header_line = if let Some(action) = typing_action { + let mut spans = vec![Span::styled( + format!("👤 {}", chat.title), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )]; + if let Some(username) = &chat.username { + spans.push(Span::styled(format!(" {}", username), Style::default().fg(Color::Gray))); + } + spans.push(Span::styled( + format!(" {}", action), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::ITALIC), + )); + Line::from(spans) + } else { + let header_text = match &chat.username { + Some(username) => format!("👤 {} {}", chat.title, username), + None => format!("👤 {}", chat.title), + }; + Line::from(Span::styled( + header_text, + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )) + }; + + let header = Paragraph::new(header_line).block(Block::default().borders(Borders::ALL)); + f.render_widget(header, area); +} diff --git a/src/ui/messages/list.rs b/src/ui/messages/list.rs new file mode 100644 index 0000000..0223c83 --- /dev/null +++ b/src/ui/messages/list.rs @@ -0,0 +1,286 @@ +use crate::app::methods::messages::MessageMethods; +use crate::app::App; +use crate::message_grouping::{group_messages, MessageGroup}; +use crate::tdlib::TdClientTrait; +use crate::ui::components; +use ratatui::{ + layout::Rect, + style::{Color, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; + +/// Информация о строке после переноса: текст и позиция в оригинале. +pub(crate) struct WrappedLine { + pub text: String, +} + +/// Разбивает текст на строки с учётом максимальной ширины. +pub(crate) fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec { + if max_width == 0 { + return vec![WrappedLine { text: text.to_string() }]; + } + + let mut result = Vec::new(); + let mut current_line = String::new(); + let mut current_width = 0; + + let chars: Vec = text.chars().collect(); + let mut word_start = 0; + let mut in_word = false; + + for (i, ch) in chars.iter().enumerate() { + if ch.is_whitespace() { + if in_word { + let word: String = chars[word_start..i].iter().collect(); + let word_width = word.chars().count(); + + if current_width == 0 { + current_line = word; + current_width = word_width; + } else if current_width + 1 + word_width <= max_width { + current_line.push(' '); + current_line.push_str(&word); + current_width += 1 + word_width; + } else { + result.push(WrappedLine { text: current_line }); + current_line = word; + current_width = word_width; + } + in_word = false; + } + } else if !in_word { + word_start = i; + in_word = true; + } + } + + if in_word { + let word: String = chars[word_start..].iter().collect(); + let word_width = word.chars().count(); + + if current_width == 0 { + current_line = word; + } else if current_width + 1 + word_width <= max_width { + current_line.push(' '); + current_line.push_str(&word); + } else { + result.push(WrappedLine { text: current_line }); + current_line = word; + } + } + + if !current_line.is_empty() { + result.push(WrappedLine { text: current_line }); + } + + if result.is_empty() { + result.push(WrappedLine { text: String::new() }); + } + + result +} + +/// Рендерит список сообщений с группировкой по дате/отправителю и автоскроллом. +pub(super) fn render_message_list(f: &mut Frame, area: Rect, app: &mut App) { + let content_width = area.width.saturating_sub(2) as usize; + let mut lines: Vec = Vec::new(); + + let selected_msg_id = app.get_selected_message().map(|m| m.id()); + let mut selected_msg_line: Option = None; + + #[cfg(feature = "images")] + let mut deferred_images: Vec = Vec::new(); + + 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; + + for group in grouped { + match group { + MessageGroup::DateSeparator(date) => { + lines.extend(components::render_date_separator(date, content_width, is_first_date)); + is_first_date = false; + is_first_sender = true; + } + MessageGroup::SenderHeader { is_outgoing, sender_name } => { + lines.extend(components::render_sender_header( + is_outgoing, + &sender_name, + content_width, + is_first_sender, + )); + is_first_sender = false; + } + MessageGroup::Message(msg) => { + let is_selected = selected_msg_id == Some(msg.id()); + if is_selected { + selected_msg_line = Some(lines.len()); + } + + let bubble_lines = components::render_message_bubble( + msg, + app.config(), + content_width, + selected_msg_id, + app.playback_state.as_ref(), + ); + + #[cfg(feature = "images")] + if let Some(photo) = msg.photo_info() { + if let crate::tdlib::PhotoDownloadState::Downloaded(path) = + &photo.download_state + { + let inline_width = + content_width.min(crate::constants::INLINE_IMAGE_MAX_WIDTH); + let img_height = components::calculate_image_height( + photo.width, + photo.height, + inline_width, + ); + let img_width = inline_width as u16; + let bubble_len = bubble_lines.len(); + let placeholder_start = lines.len() + bubble_len - img_height as usize; + + deferred_images.push(components::DeferredImageRender { + message_id: msg.id(), + photo_path: path.clone(), + line_offset: placeholder_start, + x_offset: 0, + width: img_width, + height: img_height, + }); + } + } + + lines.extend(bubble_lines); + } + MessageGroup::Album(album_messages) => { + #[cfg(feature = "images")] + { + let is_selected = album_messages + .iter() + .any(|m| selected_msg_id == Some(m.id())); + if is_selected { + selected_msg_line = Some(lines.len()); + } + + let (bubble_lines, album_deferred) = components::render_album_bubble( + &album_messages, + app.config(), + content_width, + selected_msg_id, + ); + + for mut d in album_deferred { + d.line_offset += lines.len(); + deferred_images.push(d); + } + + lines.extend(bubble_lines); + } + #[cfg(not(feature = "images"))] + { + for msg in &album_messages { + let is_selected = selected_msg_id == Some(msg.id()); + if is_selected { + selected_msg_line = Some(lines.len()); + } + lines.extend(components::render_message_bubble( + msg, + app.config(), + content_width, + selected_msg_id, + app.playback_state.as_ref(), + )); + } + } + } + } + } + + if lines.is_empty() { + lines.push(Line::from(Span::styled("Нет сообщений", Style::default().fg(Color::Gray)))); + } + + let visible_height = area.height.saturating_sub(2) as usize; + let total_lines = lines.len(); + let base_scroll = total_lines.saturating_sub(visible_height); + + let scroll_offset = if app.is_selecting_message() { + if let Some(selected_line) = selected_msg_line { + if selected_line < visible_height / 2 { + 0 + } else if selected_line > total_lines.saturating_sub(visible_height / 2) { + base_scroll + } else { + selected_line.saturating_sub(visible_height / 2) + } + } else { + base_scroll.saturating_sub(app.message_scroll_offset) + } + } else { + base_scroll.saturating_sub(app.message_scroll_offset) + } as u16; + + let messages_widget = Paragraph::new(lines) + .block(Block::default().borders(Borders::ALL)) + .scroll((scroll_offset, 0)); + f.render_widget(messages_widget, area); + + #[cfg(feature = "images")] + render_deferred_images(f, area, app, &deferred_images, visible_height, scroll_offset); +} + +#[cfg(feature = "images")] +fn render_deferred_images( + f: &mut Frame, + area: Rect, + app: &mut App, + deferred_images: &[components::DeferredImageRender], + visible_height: usize, + scroll_offset: u16, +) { + use ratatui_image::StatefulImage; + + let should_render_images = app + .last_image_render_time + .map(|t| t.elapsed() > std::time::Duration::from_millis(66)) + .unwrap_or(true); + + if deferred_images.is_empty() || !should_render_images { + return; + } + + let content_x = area.x + 1; + let content_y = area.y + 1; + + for d in deferred_images { + let y_in_content = d.line_offset as i32 - scroll_offset as i32; + + if y_in_content < 0 || y_in_content as usize >= visible_height { + continue; + } + + let img_y = content_y + y_in_content as u16; + let remaining_height = (content_y + visible_height as u16).saturating_sub(img_y); + + if d.height > remaining_height { + continue; + } + + let img_rect = Rect::new(content_x + d.x_offset, img_y, d.width, d.height); + + if let Some(renderer) = &mut app.inline_image_renderer { + let _ = renderer.load_image(d.message_id, &d.photo_path); + + if let Some(protocol) = renderer.get_protocol(&d.message_id) { + f.render_stateful_widget(StatefulImage::default(), img_rect, protocol); + } + } + } + + app.last_image_render_time = Some(std::time::Instant::now()); +} diff --git a/src/ui/messages/pinned.rs b/src/ui/messages/pinned.rs new file mode 100644 index 0000000..5947a80 --- /dev/null +++ b/src/ui/messages/pinned.rs @@ -0,0 +1,38 @@ +use crate::app::App; +use crate::tdlib::TdClientTrait; +use ratatui::{ + layout::Rect, + style::{Color, Style}, + text::{Line, Span}, + widgets::Paragraph, + Frame, +}; + +pub(super) fn render_pinned_bar(f: &mut Frame, area: Rect, app: &App) { + let Some(pinned_msg) = app.td_client.current_pinned_message() else { + return; + }; + + let pinned_preview: String = pinned_msg.text().chars().take(40).collect(); + let ellipsis = if pinned_msg.text().chars().count() > 40 { + "..." + } else { + "" + }; + let pinned_datetime = crate::utils::format_datetime(pinned_msg.date()); + let pinned_text = format!("📌 {} {}{}", pinned_datetime, pinned_preview, ellipsis); + let pinned_hint = "Ctrl+P"; + + let pinned_bar_width = area.width as usize; + let text_len = pinned_text.chars().count(); + let hint_len = pinned_hint.chars().count(); + let padding = pinned_bar_width.saturating_sub(text_len + hint_len + 2); + + let pinned_line = Line::from(vec![ + Span::styled(pinned_text, Style::default().fg(Color::Magenta)), + Span::raw(" ".repeat(padding)), + Span::styled(pinned_hint, Style::default().fg(Color::Gray)), + ]); + let pinned_bar = Paragraph::new(pinned_line).style(Style::default().bg(Color::Rgb(40, 20, 40))); + f.render_widget(pinned_bar, area); +}