fixes
This commit is contained in:
@@ -3,6 +3,16 @@
|
||||
use crate::tdlib::{MessageInfo, ProfileInfo};
|
||||
use crate::types::MessageId;
|
||||
|
||||
/// Vim-like input mode for chat view
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum InputMode {
|
||||
/// Normal mode — navigation and commands (default)
|
||||
#[default]
|
||||
Normal,
|
||||
/// Insert mode — text input only
|
||||
Insert,
|
||||
}
|
||||
|
||||
/// Состояния чата - взаимоисключающие режимы работы с чатом
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ChatState {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! Handles chat list navigation and selection
|
||||
|
||||
use crate::app::{App, ChatState};
|
||||
use crate::app::{App, ChatState, InputMode};
|
||||
use crate::app::methods::search::SearchMethods;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
|
||||
@@ -84,6 +84,7 @@ impl<T: TdClientTrait> NavigationMethods<T> for App<T> {
|
||||
self.last_typing_sent = None;
|
||||
// Сбрасываем состояние чата в нормальный режим
|
||||
self.chat_state = ChatState::Normal;
|
||||
self.input_mode = InputMode::Normal;
|
||||
// Очищаем данные в TdClient
|
||||
self.td_client.set_current_chat_id(None);
|
||||
self.td_client.clear_current_chat_messages();
|
||||
|
||||
@@ -9,7 +9,7 @@ mod state;
|
||||
pub mod methods;
|
||||
|
||||
pub use chat_filter::{ChatFilter, ChatFilterCriteria};
|
||||
pub use chat_state::ChatState;
|
||||
pub use chat_state::{ChatState, InputMode};
|
||||
pub use state::AppScreen;
|
||||
pub use methods::*;
|
||||
|
||||
@@ -60,6 +60,8 @@ pub struct App<T: TdClientTrait = TdClient> {
|
||||
pub td_client: T,
|
||||
/// Состояние чата - type-safe state machine (новое!)
|
||||
pub chat_state: ChatState,
|
||||
/// Vim-like input mode: Normal (navigation) / Insert (text input)
|
||||
pub input_mode: InputMode,
|
||||
// Auth state (приватные, доступ через геттеры)
|
||||
phone_input: String,
|
||||
code_input: String,
|
||||
@@ -144,6 +146,7 @@ impl<T: TdClientTrait> App<T> {
|
||||
screen: AppScreen::Loading,
|
||||
td_client,
|
||||
chat_state: ChatState::Normal,
|
||||
input_mode: InputMode::Normal,
|
||||
phone_input: String::new(),
|
||||
code_input: String::new(),
|
||||
password_input: String::new(),
|
||||
|
||||
@@ -65,6 +65,9 @@ pub enum Command {
|
||||
MoveToStart,
|
||||
MoveToEnd,
|
||||
|
||||
// Vim mode
|
||||
EnterInsertMode,
|
||||
|
||||
// Profile
|
||||
OpenProfile,
|
||||
}
|
||||
@@ -100,6 +103,13 @@ impl KeyBinding {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_alt(key: KeyCode) -> Self {
|
||||
Self {
|
||||
key,
|
||||
modifiers: KeyModifiers::ALT,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn matches(&self, event: &KeyEvent) -> bool {
|
||||
self.key == event.code && self.modifiers == event.modifiers
|
||||
}
|
||||
@@ -234,9 +244,7 @@ impl Keybindings {
|
||||
bindings.insert(Command::Cancel, vec![
|
||||
KeyBinding::new(KeyCode::Esc),
|
||||
]);
|
||||
bindings.insert(Command::NewLine, vec![
|
||||
KeyBinding::with_shift(KeyCode::Enter),
|
||||
]);
|
||||
bindings.insert(Command::NewLine, vec![]);
|
||||
bindings.insert(Command::DeleteChar, vec![
|
||||
KeyBinding::new(KeyCode::Backspace),
|
||||
]);
|
||||
@@ -253,6 +261,12 @@ impl Keybindings {
|
||||
KeyBinding::with_ctrl(KeyCode::Char('e')),
|
||||
]);
|
||||
|
||||
// Vim mode
|
||||
bindings.insert(Command::EnterInsertMode, vec![
|
||||
KeyBinding::new(KeyCode::Char('i')),
|
||||
KeyBinding::new(KeyCode::Char('ш')), // RU
|
||||
]);
|
||||
|
||||
// Profile
|
||||
bindings.insert(Command::OpenProfile, vec![
|
||||
KeyBinding::with_ctrl(KeyCode::Char('u')), // Ctrl+U instead of Ctrl+I
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! - Loading older messages
|
||||
|
||||
use crate::app::App;
|
||||
use crate::app::InputMode;
|
||||
use crate::app::methods::{
|
||||
compose::ComposeMethods, messages::MessageMethods,
|
||||
modal::ModalMethods, navigation::NavigationMethods,
|
||||
@@ -48,8 +49,13 @@ pub async fn handle_message_selection<T: TdClientTrait>(app: &mut App<T>, _key:
|
||||
};
|
||||
}
|
||||
}
|
||||
Some(crate::config::Command::EnterInsertMode) => {
|
||||
app.input_mode = InputMode::Insert;
|
||||
app.chat_state = crate::app::ChatState::Normal;
|
||||
}
|
||||
Some(crate::config::Command::ReplyMessage) => {
|
||||
app.start_reply_to_selected();
|
||||
app.input_mode = InputMode::Insert;
|
||||
}
|
||||
Some(crate::config::Command::ForwardMessage) => {
|
||||
app.start_forward_selected();
|
||||
@@ -243,7 +249,9 @@ pub async fn handle_enter_key<T: TdClientTrait>(app: &mut App<T>) {
|
||||
|
||||
// Сценарий 2: Режим выбора сообщения - начать редактирование
|
||||
if app.is_selecting_message() {
|
||||
if !app.start_editing_selected() {
|
||||
if app.start_editing_selected() {
|
||||
app.input_mode = InputMode::Insert;
|
||||
} else {
|
||||
// Нельзя редактировать это сообщение
|
||||
app.chat_state = crate::app::ChatState::Normal;
|
||||
}
|
||||
@@ -452,24 +460,16 @@ pub async fn handle_open_chat_keyboard_input<T: TdClientTrait>(app: &mut App<T>,
|
||||
// Курсор в конец
|
||||
app.cursor_position = app.message_input.chars().count();
|
||||
}
|
||||
// Стрелки вверх/вниз - скролл сообщений или начало выбора
|
||||
// Стрелки вверх/вниз - скролл сообщений (в Insert mode)
|
||||
KeyCode::Down => {
|
||||
// Скролл вниз (к новым сообщениям)
|
||||
if app.message_scroll_offset > 0 {
|
||||
app.message_scroll_offset = app.message_scroll_offset.saturating_sub(3);
|
||||
}
|
||||
}
|
||||
KeyCode::Up => {
|
||||
// Если инпут пустой и не в режиме редактирования — начать выбор сообщения
|
||||
if app.message_input.is_empty() && !app.is_editing() {
|
||||
app.start_message_selection();
|
||||
} else {
|
||||
// Скролл вверх (к старым сообщениям)
|
||||
app.message_scroll_offset += 3;
|
||||
|
||||
// Подгружаем старые сообщения если нужно
|
||||
load_older_messages_if_needed(app).await;
|
||||
}
|
||||
// В Insert mode — только скролл
|
||||
app.message_scroll_offset += 3;
|
||||
load_older_messages_if_needed(app).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
//! - Opening chats
|
||||
|
||||
use crate::app::App;
|
||||
use crate::app::methods::{compose::ComposeMethods, navigation::NavigationMethods};
|
||||
use crate::app::InputMode;
|
||||
use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods, navigation::NavigationMethods};
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crate::types::{ChatId, MessageId};
|
||||
use crate::utils::{with_timeout, with_timeout_msg, with_timeout_ignore};
|
||||
@@ -135,6 +136,10 @@ pub async fn open_chat_and_load_data<T: TdClientTrait>(app: &mut App<T>, chat_id
|
||||
// Загружаем черновик
|
||||
app.load_draft();
|
||||
app.status_message = None;
|
||||
|
||||
// Vim mode: Normal + MessageSelection по умолчанию
|
||||
app.input_mode = InputMode::Normal;
|
||||
app.start_message_selection();
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(e);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! Priority order: modals → search → compose → chat → chat list.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::app::InputMode;
|
||||
use crate::app::methods::{
|
||||
compose::ComposeMethods,
|
||||
messages::MessageMethods,
|
||||
@@ -30,14 +31,10 @@ use crossterm::event::KeyEvent;
|
||||
|
||||
|
||||
|
||||
/// Обработка клавиши Esc
|
||||
///
|
||||
/// Обрабатывает отмену текущего действия или закрытие чата:
|
||||
/// - В режиме выбора сообщения: отменить выбор
|
||||
/// - В режиме редактирования: отменить редактирование
|
||||
/// - В режиме ответа: отменить ответ
|
||||
/// - В открытом чате: сохранить черновик и закрыть чат
|
||||
async fn handle_escape_key<T: TdClientTrait>(app: &mut App<T>) {
|
||||
/// Обработка клавиши Esc в Normal mode
|
||||
///
|
||||
/// Закрывает чат с сохранением черновика
|
||||
async fn handle_escape_normal<T: TdClientTrait>(app: &mut App<T>) {
|
||||
// Закрываем модальное окно изображения если открыто
|
||||
#[cfg(feature = "images")]
|
||||
if app.image_modal.is_some() {
|
||||
@@ -46,34 +43,16 @@ async fn handle_escape_key<T: TdClientTrait>(app: &mut App<T>) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Early return для режима выбора сообщения
|
||||
if app.is_selecting_message() {
|
||||
app.chat_state = crate::app::ChatState::Normal;
|
||||
return;
|
||||
}
|
||||
|
||||
// Early return для режима редактирования
|
||||
if app.is_editing() {
|
||||
app.cancel_editing();
|
||||
return;
|
||||
}
|
||||
|
||||
// Early return для режима ответа
|
||||
if app.is_replying() {
|
||||
app.cancel_reply();
|
||||
return;
|
||||
}
|
||||
|
||||
// Закрытие чата с сохранением черновика
|
||||
let Some(chat_id) = app.selected_chat_id else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Сохраняем черновик если есть текст в инпуте
|
||||
if !app.message_input.is_empty() && !app.is_editing() && !app.is_replying() {
|
||||
if !app.message_input.is_empty() {
|
||||
let draft_text = app.message_input.clone();
|
||||
let _ = app.td_client.set_draft_message(chat_id, draft_text).await;
|
||||
} else if app.message_input.is_empty() {
|
||||
} else {
|
||||
// Очищаем черновик если инпут пустой
|
||||
let _ = app.td_client.set_draft_message(chat_id, String::new()).await;
|
||||
}
|
||||
@@ -81,79 +60,169 @@ async fn handle_escape_key<T: TdClientTrait>(app: &mut App<T>) {
|
||||
app.close_chat();
|
||||
}
|
||||
|
||||
/// Обработка клавиши Esc в Insert mode
|
||||
///
|
||||
/// Отменяет Reply/Editing и возвращает в Normal + MessageSelection
|
||||
fn handle_escape_insert<T: TdClientTrait>(app: &mut App<T>) {
|
||||
if app.is_editing() {
|
||||
app.cancel_editing();
|
||||
}
|
||||
if app.is_replying() {
|
||||
app.cancel_reply();
|
||||
}
|
||||
app.input_mode = InputMode::Normal;
|
||||
app.start_message_selection();
|
||||
}
|
||||
|
||||
/// Главный обработчик ввода - роутер для всех режимов приложения
|
||||
pub async fn handle<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
|
||||
// Глобальные команды (работают всегда)
|
||||
let command = app.get_command(key);
|
||||
|
||||
// 1. Insert mode + чат открыт → только текст, Enter, Esc
|
||||
// (Ctrl+C обрабатывается в main.rs до вызова router)
|
||||
if app.selected_chat_id.is_some() && app.input_mode == InputMode::Insert {
|
||||
// Модальные окна всё равно обрабатываем (image modal, delete confirmation etc.)
|
||||
#[cfg(feature = "images")]
|
||||
if app.image_modal.is_some() {
|
||||
handle_image_modal_mode(app, key).await;
|
||||
return;
|
||||
}
|
||||
if app.is_confirm_delete_shown() {
|
||||
handle_delete_confirmation(app, key).await;
|
||||
return;
|
||||
}
|
||||
if app.is_reaction_picker_mode() {
|
||||
handle_reaction_picker_mode(app, key, command).await;
|
||||
return;
|
||||
}
|
||||
if app.is_profile_mode() {
|
||||
handle_profile_mode(app, key, command).await;
|
||||
return;
|
||||
}
|
||||
if app.is_message_search_mode() {
|
||||
handle_message_search_mode(app, key, command).await;
|
||||
return;
|
||||
}
|
||||
if app.is_pinned_mode() {
|
||||
handle_pinned_mode(app, key, command).await;
|
||||
return;
|
||||
}
|
||||
if app.is_forwarding() {
|
||||
handle_forward_mode(app, key, command).await;
|
||||
return;
|
||||
}
|
||||
|
||||
match command {
|
||||
Some(crate::config::Command::Cancel) => {
|
||||
handle_escape_insert(app);
|
||||
return;
|
||||
}
|
||||
Some(crate::config::Command::SubmitMessage) => {
|
||||
handle_enter_key(app).await;
|
||||
return;
|
||||
}
|
||||
Some(crate::config::Command::DeleteWord) => {
|
||||
// Ctrl+W → удалить слово
|
||||
if app.cursor_position > 0 {
|
||||
let chars: Vec<char> = app.message_input.chars().collect();
|
||||
let mut new_pos = app.cursor_position;
|
||||
// Пропускаем пробелы
|
||||
while new_pos > 0 && chars[new_pos - 1] == ' ' {
|
||||
new_pos -= 1;
|
||||
}
|
||||
// Пропускаем слово
|
||||
while new_pos > 0 && chars[new_pos - 1] != ' ' {
|
||||
new_pos -= 1;
|
||||
}
|
||||
let new_input: String = chars[..new_pos]
|
||||
.iter()
|
||||
.chain(chars[app.cursor_position..].iter())
|
||||
.collect();
|
||||
app.message_input = new_input;
|
||||
app.cursor_position = new_pos;
|
||||
}
|
||||
return;
|
||||
}
|
||||
Some(crate::config::Command::MoveToStart) => {
|
||||
app.cursor_position = 0;
|
||||
return;
|
||||
}
|
||||
Some(crate::config::Command::MoveToEnd) => {
|
||||
app.cursor_position = app.message_input.chars().count();
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Весь остальной ввод → текст
|
||||
handle_open_chat_keyboard_input(app, key).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Глобальные команды (Ctrl+R, Ctrl+S, Ctrl+P, Ctrl+F)
|
||||
if handle_global_commands(app, key).await {
|
||||
return;
|
||||
}
|
||||
|
||||
// Получаем команду из keybindings
|
||||
let command = app.get_command(key);
|
||||
|
||||
// Модальное окно просмотра изображения (приоритет высокий)
|
||||
// 4. Модальное окно просмотра изображения
|
||||
#[cfg(feature = "images")]
|
||||
if app.image_modal.is_some() {
|
||||
handle_image_modal_mode(app, key).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Режим профиля
|
||||
// 5. Режим профиля
|
||||
if app.is_profile_mode() {
|
||||
handle_profile_mode(app, key, command).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Режим поиска по сообщениям
|
||||
// 6. Режим поиска по сообщениям
|
||||
if app.is_message_search_mode() {
|
||||
handle_message_search_mode(app, key, command).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Режим просмотра закреплённых сообщений
|
||||
// 7. Режим просмотра закреплённых сообщений
|
||||
if app.is_pinned_mode() {
|
||||
handle_pinned_mode(app, key, command).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Обработка ввода в режиме выбора реакции
|
||||
// 8. Обработка ввода в режиме выбора реакции
|
||||
if app.is_reaction_picker_mode() {
|
||||
handle_reaction_picker_mode(app, key, command).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Модалка подтверждения удаления
|
||||
// 9. Модалка подтверждения удаления
|
||||
if app.is_confirm_delete_shown() {
|
||||
handle_delete_confirmation(app, key).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Режим выбора чата для пересылки
|
||||
// 10. Режим выбора чата для пересылки
|
||||
if app.is_forwarding() {
|
||||
handle_forward_mode(app, key, command).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Режим поиска
|
||||
// 11. Режим поиска чатов
|
||||
if app.is_searching {
|
||||
handle_chat_search_mode(app, key, command).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Обработка команд через keybindings
|
||||
// 12. Normal mode commands (Enter, Esc, Profile)
|
||||
match command {
|
||||
Some(crate::config::Command::SubmitMessage) => {
|
||||
// Enter - открыть чат, отправить сообщение или редактировать
|
||||
handle_enter_key(app).await;
|
||||
return;
|
||||
}
|
||||
Some(crate::config::Command::Cancel) => {
|
||||
// Esc - отменить выбор/редактирование/reply или закрыть чат
|
||||
handle_escape_key(app).await;
|
||||
handle_escape_normal(app).await;
|
||||
return;
|
||||
}
|
||||
Some(crate::config::Command::OpenProfile) => {
|
||||
// Открыть профиль (обычно 'i')
|
||||
if app.selected_chat_id.is_some() {
|
||||
handle_profile_open(app).await;
|
||||
return;
|
||||
@@ -162,17 +231,15 @@ pub async fn handle<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Режим открытого чата
|
||||
// 13. Normal mode в чате → MessageSelection
|
||||
if app.selected_chat_id.is_some() {
|
||||
// Режим выбора сообщения для редактирования/удаления
|
||||
if app.is_selecting_message() {
|
||||
handle_message_selection(app, key, command).await;
|
||||
return;
|
||||
// Auto-enter MessageSelection if not already in it
|
||||
if !app.is_selecting_message() {
|
||||
app.start_message_selection();
|
||||
}
|
||||
|
||||
handle_open_chat_keyboard_input(app, key).await;
|
||||
handle_message_selection(app, key, command).await;
|
||||
} else {
|
||||
// В режиме списка чатов - навигация стрелками и переключение папок
|
||||
// 14. Список чатов
|
||||
handle_chat_list_navigation(app, key, command).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,19 +24,40 @@ struct WrappedLine {
|
||||
start_offset: usize,
|
||||
}
|
||||
|
||||
/// Разбивает текст на строки с учётом максимальной ширины
|
||||
/// Разбивает текст на строки с учётом максимальной ширины и `\n`
|
||||
fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
||||
let mut all_lines = Vec::new();
|
||||
let mut char_offset = 0;
|
||||
|
||||
for segment in text.split('\n') {
|
||||
let wrapped = wrap_paragraph(segment, max_width, char_offset);
|
||||
all_lines.extend(wrapped);
|
||||
char_offset += segment.chars().count() + 1; // +1 за '\n'
|
||||
}
|
||||
|
||||
if all_lines.is_empty() {
|
||||
all_lines.push(WrappedLine {
|
||||
text: String::new(),
|
||||
start_offset: 0,
|
||||
});
|
||||
}
|
||||
|
||||
all_lines
|
||||
}
|
||||
|
||||
/// Разбивает один абзац (без `\n`) на строки по ширине
|
||||
fn wrap_paragraph(text: &str, max_width: usize, base_offset: usize) -> Vec<WrappedLine> {
|
||||
if max_width == 0 {
|
||||
return vec![WrappedLine {
|
||||
text: text.to_string(),
|
||||
start_offset: 0,
|
||||
start_offset: base_offset,
|
||||
}];
|
||||
}
|
||||
|
||||
let mut result = Vec::new();
|
||||
let mut current_line = String::new();
|
||||
let mut current_width = 0;
|
||||
let mut line_start_offset = 0;
|
||||
let mut line_start_offset = base_offset;
|
||||
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut word_start = 0;
|
||||
@@ -51,7 +72,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
||||
if current_width == 0 {
|
||||
current_line = word;
|
||||
current_width = word_width;
|
||||
line_start_offset = word_start;
|
||||
line_start_offset = base_offset + word_start;
|
||||
} else if current_width + 1 + word_width <= max_width {
|
||||
current_line.push(' ');
|
||||
current_line.push_str(&word);
|
||||
@@ -63,7 +84,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
||||
});
|
||||
current_line = word;
|
||||
current_width = word_width;
|
||||
line_start_offset = word_start;
|
||||
line_start_offset = base_offset + word_start;
|
||||
}
|
||||
in_word = false;
|
||||
}
|
||||
@@ -79,7 +100,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
||||
|
||||
if current_width == 0 {
|
||||
current_line = word;
|
||||
line_start_offset = word_start;
|
||||
line_start_offset = base_offset + word_start;
|
||||
} else if current_width + 1 + word_width <= max_width {
|
||||
current_line.push(' ');
|
||||
current_line.push_str(&word);
|
||||
@@ -89,7 +110,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
||||
start_offset: line_start_offset,
|
||||
});
|
||||
current_line = word;
|
||||
line_start_offset = word_start;
|
||||
line_start_offset = base_offset + word_start;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +124,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
||||
if result.is_empty() {
|
||||
result.push(WrappedLine {
|
||||
text: String::new(),
|
||||
start_offset: 0,
|
||||
start_offset: base_offset,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -288,11 +309,15 @@ pub fn render_message_bubble(
|
||||
let full_len = line_len + time_mark_len + marker_len;
|
||||
let padding = content_width.saturating_sub(full_len + 1);
|
||||
let mut line_spans = vec![Span::raw(" ".repeat(padding))];
|
||||
if is_selected {
|
||||
if is_selected && i == 0 {
|
||||
// Одна строка — маркер на ней
|
||||
line_spans.push(Span::styled(
|
||||
selection_marker,
|
||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
} else if is_selected {
|
||||
// Последняя строка multi-line — пробелы вместо маркера
|
||||
line_spans.push(Span::raw(" ".repeat(marker_len)));
|
||||
}
|
||||
line_spans.extend(formatted_spans);
|
||||
line_spans.push(Span::styled(format!(" {}", time_mark), Style::default().fg(Color::Gray)));
|
||||
@@ -305,6 +330,9 @@ pub fn render_message_bubble(
|
||||
selection_marker,
|
||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
} else if is_selected {
|
||||
// Средние строки multi-line — пробелы вместо маркера
|
||||
line_spans.push(Span::raw(" ".repeat(marker_len)));
|
||||
}
|
||||
line_spans.extend(formatted_spans);
|
||||
lines.push(Line::from(line_spans));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Compose bar / input box rendering
|
||||
|
||||
use crate::app::App;
|
||||
use crate::app::InputMode;
|
||||
use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods};
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crate::ui::components;
|
||||
@@ -19,13 +20,12 @@ fn render_input_with_cursor(
|
||||
cursor_pos: usize,
|
||||
color: Color,
|
||||
) -> Line<'static> {
|
||||
// Используем компонент input_field
|
||||
components::render_input_field(prefix, text, cursor_pos, color)
|
||||
}
|
||||
|
||||
/// Renders input box with support for different modes (forward/select/edit/reply/normal)
|
||||
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
||||
let (input_line, input_title) = if app.is_forwarding() {
|
||||
let (input_line, input_title): (Line, &str) = if app.is_forwarding() {
|
||||
// Режим пересылки - показываем превью сообщения
|
||||
let forward_preview = app
|
||||
.get_forwarding_message()
|
||||
@@ -67,7 +67,6 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
||||
} else if app.is_editing() {
|
||||
// Режим редактирования
|
||||
if app.message_input.is_empty() {
|
||||
// Пустой инпут - показываем курсор и placeholder
|
||||
let line = Line::from(vec![
|
||||
Span::raw("✏ "),
|
||||
Span::styled("█", Style::default().fg(Color::Magenta)),
|
||||
@@ -75,7 +74,6 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
||||
]);
|
||||
(line, " Редактирование (Esc отмена) ")
|
||||
} else {
|
||||
// Текст с курсором
|
||||
let line = render_input_with_cursor(
|
||||
"✏ ",
|
||||
&app.message_input,
|
||||
@@ -123,10 +121,25 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
||||
);
|
||||
(line, " Ответ (Esc отмена) ")
|
||||
}
|
||||
} else {
|
||||
// Обычный режим
|
||||
} else if app.input_mode == InputMode::Normal {
|
||||
// Normal mode — dim, no cursor
|
||||
if app.message_input.is_empty() {
|
||||
let line = Line::from(vec![
|
||||
Span::styled("> Press i to type...", Style::default().fg(Color::DarkGray)),
|
||||
]);
|
||||
(line, "")
|
||||
} else {
|
||||
let draft_preview: String = app.message_input.chars().take(60).collect();
|
||||
let ellipsis = if app.message_input.chars().count() > 60 { "..." } else { "" };
|
||||
let line = Line::from(Span::styled(
|
||||
format!("> {}{}", draft_preview, ellipsis),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
(line, "")
|
||||
}
|
||||
} else {
|
||||
// Insert mode — active, with cursor
|
||||
if app.message_input.is_empty() {
|
||||
// Пустой инпут - показываем курсор и placeholder
|
||||
let line = Line::from(vec![
|
||||
Span::raw("> "),
|
||||
Span::styled("█", Style::default().fg(Color::Yellow)),
|
||||
@@ -134,7 +147,6 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
||||
]);
|
||||
(line, "")
|
||||
} else {
|
||||
// Текст с курсором
|
||||
let line = render_input_with_cursor(
|
||||
"> ",
|
||||
&app.message_input,
|
||||
@@ -146,7 +158,12 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
||||
};
|
||||
|
||||
let input_block = if input_title.is_empty() {
|
||||
Block::default().borders(Borders::ALL)
|
||||
let border_style = if app.input_mode == InputMode::Insert {
|
||||
Style::default().fg(Color::Green)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
Block::default().borders(Borders::ALL).border_style(border_style)
|
||||
} else {
|
||||
let title_color = if app.is_replying() || app.is_forwarding() {
|
||||
Color::Cyan
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::app::App;
|
||||
use crate::app::InputMode;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crate::tdlib::NetworkState;
|
||||
use ratatui::{
|
||||
@@ -25,7 +26,11 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
||||
} else if app.is_searching {
|
||||
format!(" {}↑/↓: Navigate | Enter: Select | Esc: Cancel ", network_indicator)
|
||||
} else if app.selected_chat_id.is_some() {
|
||||
format!(" {}↑/↓: Scroll | Ctrl+U: Profile | Enter: Send | Esc: Close | Ctrl+R: Refresh | Ctrl+C: Quit ", network_indicator)
|
||||
let mode_str = match app.input_mode {
|
||||
InputMode::Normal => "[NORMAL] j/k: Nav | i: Insert | d/r/f/y: Actions | Esc: Close",
|
||||
InputMode::Insert => "[INSERT] Type message | Esc: Normal mode",
|
||||
};
|
||||
format!(" {}{} | Ctrl+C: Quit ", network_indicator, mode_str)
|
||||
} else {
|
||||
format!(
|
||||
" {}↑/↓: Navigate | Enter: Open | Ctrl+S: Search | Ctrl+R: Refresh | Ctrl+C: Quit ",
|
||||
|
||||
@@ -385,9 +385,9 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
|
||||
if let Some(chat) = app.get_selected_chat().cloned() {
|
||||
// Вычисляем динамическую высоту инпута на основе длины текста
|
||||
let input_width = area.width.saturating_sub(4) as usize; // -2 для рамок, -2 для "> "
|
||||
let input_text_len = app.message_input.chars().count() + 2; // +2 для "> "
|
||||
let input_lines = if input_width > 0 {
|
||||
((input_text_len as f32 / input_width as f32).ceil() as u16).max(1)
|
||||
let input_lines: u16 = if input_width > 0 {
|
||||
let len = app.message_input.chars().count() + 2; // +2 для "> "
|
||||
((len as f32 / input_width as f32).ceil() as u16).max(1)
|
||||
} else {
|
||||
1
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user