This commit is contained in:
Mikhail Kilin
2026-02-13 19:52:53 +03:00
parent 6d08300daa
commit 6639dc876c
38 changed files with 961 additions and 123 deletions

View File

@@ -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 {

View File

@@ -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();

View File

@@ -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(),

View File

@@ -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

View File

@@ -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;
}
_ => {}
}

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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));

View File

@@ -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

View File

@@ -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 ",

View File

@@ -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
};