refactor: complete Phase 13 deep architecture refactoring (etaps 3-7)

Split monolithic files into modular architecture:
- ui/messages.rs (893→365 lines): extract modals/, compose_bar.rs
- tdlib/messages.rs (836→3 files): split into messages/mod, convert, operations
- config/mod.rs (642→3 files): extract validation.rs, loader.rs
- Code duplication cleanup: shared components, ~220 lines removed
- Documentation: PROJECT_STRUCTURE.md rewrite, 16 files got //! docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikhail Kilin
2026-02-06 15:28:11 +03:00
parent 931954d829
commit ffd52d2384
39 changed files with 1706 additions and 1665 deletions

View File

@@ -1,4 +1,7 @@
//! Chat list panel: search box, chat items, and user online status.
use crate::app::App;
use crate::app::methods::{compose::ComposeMethods, search::SearchMethods};
use crate::tdlib::TdClientTrait;
use crate::tdlib::UserOnlineStatus;
use crate::ui::components;
@@ -68,55 +71,16 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
f.render_stateful_widget(chats_list, chat_chunks[1], &mut app.chat_list_state);
// User status - показываем статус выбранного чата
let (status_text, status_color) = if let Some(chat_id) = app.selected_chat_id {
match app.td_client.get_user_status_by_chat_id(chat_id) {
Some(UserOnlineStatus::Online) => ("● онлайн".to_string(), Color::Green),
Some(UserOnlineStatus::Recently) => ("был(а) недавно".to_string(), Color::Yellow),
Some(UserOnlineStatus::Offline(was_online)) => {
let formatted = format_was_online(*was_online);
(formatted, Color::Gray)
}
Some(UserOnlineStatus::LastWeek) => {
("был(а) на этой неделе".to_string(), Color::DarkGray)
}
Some(UserOnlineStatus::LastMonth) => {
("был(а) в этом месяце".to_string(), Color::DarkGray)
}
Some(UserOnlineStatus::LongTimeAgo) => ("был(а) давно".to_string(), Color::DarkGray),
None => ("".to_string(), Color::DarkGray), // Для групп/каналов
}
// User status - показываем статус выбранного или выделенного чата
let status_chat_id = if app.selected_chat_id.is_some() {
app.selected_chat_id
} else {
// Показываем статус выделенного в списке чата
let filtered = app.get_filtered_chats();
if let Some(i) = app.chat_list_state.selected() {
if let Some(chat) = filtered.get(i) {
match app.td_client.get_user_status_by_chat_id(chat.id) {
Some(UserOnlineStatus::Online) => ("● онлайн".to_string(), Color::Green),
Some(UserOnlineStatus::Recently) => {
("был(а) недавно".to_string(), Color::Yellow)
}
Some(UserOnlineStatus::Offline(was_online)) => {
let formatted = format_was_online(*was_online);
(formatted, Color::Gray)
}
Some(UserOnlineStatus::LastWeek) => {
("был(а) на этой неделе".to_string(), Color::DarkGray)
}
Some(UserOnlineStatus::LastMonth) => {
("был(а) в этом месяце".to_string(), Color::DarkGray)
}
Some(UserOnlineStatus::LongTimeAgo) => {
("был(а) давно".to_string(), Color::DarkGray)
}
None => ("".to_string(), Color::DarkGray),
}
} else {
("".to_string(), Color::DarkGray)
}
} else {
("".to_string(), Color::DarkGray)
}
app.chat_list_state.selected().and_then(|i| filtered.get(i).map(|c| c.id))
};
let (status_text, status_color) = match status_chat_id {
Some(chat_id) => format_user_status(app.td_client.get_user_status_by_chat_id(chat_id)),
None => ("".to_string(), Color::DarkGray),
};
let status = Paragraph::new(status_text)
@@ -125,7 +89,17 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
f.render_widget(status, chat_chunks[2]);
}
/// Форматирование времени "был(а) в ..."
fn format_was_online(timestamp: i32) -> String {
crate::utils::format_was_online(timestamp)
/// Форматирует статус пользователя для отображения в статус-баре
fn format_user_status(status: Option<&UserOnlineStatus>) -> (String, Color) {
match status {
Some(UserOnlineStatus::Online) => ("● онлайн".to_string(), Color::Green),
Some(UserOnlineStatus::Recently) => ("был(а) недавно".to_string(), Color::Yellow),
Some(UserOnlineStatus::Offline(was_online)) => {
(crate::utils::format_was_online(*was_online), Color::Gray)
}
Some(UserOnlineStatus::LastWeek) => ("был(а) на этой неделе".to_string(), Color::DarkGray),
Some(UserOnlineStatus::LastMonth) => ("был(а) в этом месяце".to_string(), Color::DarkGray),
Some(UserOnlineStatus::LongTimeAgo) => ("был(а) давно".to_string(), Color::DarkGray),
None => ("".to_string(), Color::DarkGray),
}
}

View File

@@ -0,0 +1,116 @@
//! Shared message list rendering for search and pinned modals
use crate::tdlib::MessageInfo;
use ratatui::{
layout::Alignment,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
/// Renders a single message item with marker, sender, date, and wrapped text
pub fn render_message_item(
msg: &MessageInfo,
is_selected: bool,
content_width: usize,
max_preview_lines: usize,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
// Marker, sender name, and date
let marker = if is_selected { "" } else { " " };
let sender_color = if msg.is_outgoing() {
Color::Green
} else {
Color::Cyan
};
let sender_name = if msg.is_outgoing() {
"Вы".to_string()
} else {
msg.sender_name().to_string()
};
lines.push(Line::from(vec![
Span::styled(
marker.to_string(),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{} ", sender_name),
Style::default()
.fg(sender_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("({})", crate::utils::format_datetime(msg.date())),
Style::default().fg(Color::Gray),
),
]));
// Wrapped message text
let msg_color = if is_selected {
Color::Yellow
} else {
Color::White
};
let max_width = content_width.saturating_sub(4);
let wrapped = crate::ui::messages::wrap_text_with_offsets(msg.text(), max_width);
let wrapped_count = wrapped.len();
for wrapped_line in wrapped.into_iter().take(max_preview_lines) {
lines.push(Line::from(vec![
Span::raw(" ".to_string()),
Span::styled(wrapped_line.text, Style::default().fg(msg_color)),
]));
}
if wrapped_count > max_preview_lines {
lines.push(Line::from(vec![
Span::raw(" ".to_string()),
Span::styled("...".to_string(), Style::default().fg(Color::Gray)),
]));
}
lines
}
/// Calculates scroll offset to keep selected item visible
pub fn calculate_scroll_offset(
selected_index: usize,
lines_per_item: usize,
visible_height: u16,
) -> u16 {
let visible = visible_height.saturating_sub(2) as usize;
let selected_line = selected_index * lines_per_item;
if selected_line > visible / 2 {
(selected_line - visible / 2) as u16
} else {
0
}
}
/// Renders a help bar with keyboard shortcuts
pub fn render_help_bar(shortcuts: &[(&str, &str, Color)], border_color: Color) -> Paragraph<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
for (i, (key, label, color)) in shortcuts.iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" ".to_string()));
}
spans.push(Span::styled(
format!(" {} ", key),
Style::default()
.fg(*color)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(label.to_string()));
}
Paragraph::new(Line::from(spans))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color)),
)
.alignment(Alignment::Center)
}

View File

@@ -1,8 +1,9 @@
// UI компоненты для переиспользования
//! Reusable UI components: message bubbles, input fields, modals, lists.
pub mod modal;
pub mod input_field;
pub mod message_bubble;
pub mod message_list;
pub mod chat_list_item;
pub mod emoji_picker;
@@ -11,3 +12,4 @@ pub use input_field::render_input_field;
pub use chat_list_item::render_chat_list_item;
pub use emoji_picker::render_emoji_picker;
pub use message_bubble::{render_date_separator, render_message_bubble, render_sender_header};
pub use message_list::{render_message_item, calculate_scroll_offset, render_help_bar};

170
src/ui/compose_bar.rs Normal file
View File

@@ -0,0 +1,170 @@
//! Compose bar / input box rendering
use crate::app::App;
use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods};
use crate::tdlib::TdClientTrait;
use crate::ui::components;
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
/// Renders input field with cursor at the specified position
fn render_input_with_cursor(
prefix: &str,
text: &str,
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 forward_preview = app
.get_forwarding_message()
.map(|m| {
let text_preview: String = m.text().chars().take(40).collect();
let ellipsis = if m.text().chars().count() > 40 {
"..."
} else {
""
};
format!("{}{}", text_preview, ellipsis)
})
.unwrap_or_else(|| "↪ ...".to_string());
let line = Line::from(Span::styled(forward_preview, Style::default().fg(Color::Cyan)));
(line, " Выберите чат ← ")
} else if app.is_selecting_message() {
// Режим выбора сообщения - подсказка зависит от возможностей
let selected_msg = app.get_selected_message();
let can_edit = selected_msg
.as_ref()
.map(|m| m.can_be_edited() && m.is_outgoing())
.unwrap_or(false);
let can_delete = selected_msg
.as_ref()
.map(|m| m.can_be_deleted_only_for_self() || m.can_be_deleted_for_all_users())
.unwrap_or(false);
let hint = match (can_edit, can_delete) {
(true, true) => "↑↓ · Enter ред. · r ответ · f перслть · y копир. · d удал. · Esc",
(true, false) => "↑↓ · Enter ред. · r ответ · f переслть · y копир. · Esc",
(false, true) => "↑↓ · r ответ · f переслать · y копир. · d удалить · Esc",
(false, false) => "↑↓ · r ответить · f переслать · y копировать · Esc",
};
(
Line::from(Span::styled(hint, Style::default().fg(Color::Cyan))),
" Выбор сообщения ",
)
} 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)),
Span::styled(" Введите новый текст...", Style::default().fg(Color::Gray)),
]);
(line, " Редактирование (Esc отмена) ")
} else {
// Текст с курсором
let line = render_input_with_cursor(
"",
&app.message_input,
app.cursor_position,
Color::Magenta,
);
(line, " Редактирование (Esc отмена) ")
}
} else if app.is_replying() {
// Режим ответа на сообщение
let reply_preview = app
.get_replying_to_message()
.map(|m| {
let sender = if m.is_outgoing() {
"Вы"
} else {
m.sender_name()
};
let text_preview: String = m.text().chars().take(30).collect();
let ellipsis = if m.text().chars().count() > 30 {
"..."
} else {
""
};
format!("{}: {}{}", sender, text_preview, ellipsis)
})
.unwrap_or_else(|| "...".to_string());
if app.message_input.is_empty() {
let line = Line::from(vec![
Span::styled("", Style::default().fg(Color::Cyan)),
Span::styled(reply_preview, Style::default().fg(Color::Gray)),
Span::raw(" "),
Span::styled("", Style::default().fg(Color::Yellow)),
]);
(line, " Ответ (Esc отмена) ")
} else {
let short_preview: String = reply_preview.chars().take(15).collect();
let prefix = format!("{} > ", short_preview);
let line = render_input_with_cursor(
&prefix,
&app.message_input,
app.cursor_position,
Color::Yellow,
);
(line, " Ответ (Esc отмена) ")
}
} else {
// Обычный режим
if app.message_input.is_empty() {
// Пустой инпут - показываем курсор и placeholder
let line = Line::from(vec![
Span::raw("> "),
Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(" Введите сообщение...", Style::default().fg(Color::Gray)),
]);
(line, "")
} else {
// Текст с курсором
let line = render_input_with_cursor(
"> ",
&app.message_input,
app.cursor_position,
Color::Yellow,
);
(line, "")
}
};
let input_block = if input_title.is_empty() {
Block::default().borders(Borders::ALL)
} else {
let title_color = if app.is_replying() || app.is_forwarding() {
Color::Cyan
} else {
Color::Magenta
};
Block::default()
.borders(Borders::ALL)
.title(input_title)
.title_style(
Style::default()
.fg(title_color)
.add_modifier(Modifier::BOLD),
)
};
let input = Paragraph::new(input_line)
.block(input_block)
.wrap(ratatui::widgets::Wrap { trim: false });
f.render_widget(input, area);
}

View File

@@ -1,7 +1,14 @@
//! 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::App;
use crate::app::methods::{messages::MessageMethods, modal::ModalMethods, search::SearchMethods};
use crate::tdlib::TdClientTrait;
use crate::message_grouping::{group_messages, MessageGroup};
use crate::ui::components;
use crate::ui::{compose_bar, modals};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
@@ -88,24 +95,14 @@ fn render_pinned_bar<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>)
f.render_widget(pinned_bar, area);
}
fn render_input_with_cursor(
prefix: &str,
text: &str,
cursor_pos: usize,
color: Color,
) -> Line<'static> {
// Используем компонент input_field
components::render_input_field(prefix, text, cursor_pos, color)
}
/// Информация о строке после переноса: текст и позиция в оригинале
struct WrappedLine {
text: String,
pub(super) struct WrappedLine {
pub text: String,
}
/// Разбивает текст на строки с учётом максимальной ширины
/// (используется только для search/pinned режимов, основной рендеринг через message_bubble)
fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
pub(super) fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
if max_width == 0 {
return vec![WrappedLine {
text: text.to_string(),
@@ -277,153 +274,6 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>
f.render_widget(messages_widget, area);
}
/// Рендерит input box с поддержкой разных режимов (forward/select/edit/reply/normal)
fn render_input_box<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
let (input_line, input_title) = if app.is_forwarding() {
// Режим пересылки - показываем превью сообщения
let forward_preview = app
.get_forwarding_message()
.map(|m| {
let text_preview: String = m.text().chars().take(40).collect();
let ellipsis = if m.text().chars().count() > 40 {
"..."
} else {
""
};
format!("{}{}", text_preview, ellipsis)
})
.unwrap_or_else(|| "↪ ...".to_string());
let line = Line::from(Span::styled(forward_preview, Style::default().fg(Color::Cyan)));
(line, " Выберите чат ← ")
} else if app.is_selecting_message() {
// Режим выбора сообщения - подсказка зависит от возможностей
let selected_msg = app.get_selected_message();
let can_edit = selected_msg
.as_ref()
.map(|m| m.can_be_edited() && m.is_outgoing())
.unwrap_or(false);
let can_delete = selected_msg
.as_ref()
.map(|m| m.can_be_deleted_only_for_self() || m.can_be_deleted_for_all_users())
.unwrap_or(false);
let hint = match (can_edit, can_delete) {
(true, true) => "↑↓ · Enter ред. · r ответ · f перслть · y копир. · d удал. · Esc",
(true, false) => "↑↓ · Enter ред. · r ответ · f переслть · y копир. · Esc",
(false, true) => "↑↓ · r ответ · f переслать · y копир. · d удалить · Esc",
(false, false) => "↑↓ · r ответить · f переслать · y копировать · Esc",
};
(
Line::from(Span::styled(hint, Style::default().fg(Color::Cyan))),
" Выбор сообщения ",
)
} 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)),
Span::styled(" Введите новый текст...", Style::default().fg(Color::Gray)),
]);
(line, " Редактирование (Esc отмена) ")
} else {
// Текст с курсором
let line = render_input_with_cursor(
"",
&app.message_input,
app.cursor_position,
Color::Magenta,
);
(line, " Редактирование (Esc отмена) ")
}
} else if app.is_replying() {
// Режим ответа на сообщение
let reply_preview = app
.get_replying_to_message()
.map(|m| {
let sender = if m.is_outgoing() {
"Вы"
} else {
m.sender_name()
};
let text_preview: String = m.text().chars().take(30).collect();
let ellipsis = if m.text().chars().count() > 30 {
"..."
} else {
""
};
format!("{}: {}{}", sender, text_preview, ellipsis)
})
.unwrap_or_else(|| "...".to_string());
if app.message_input.is_empty() {
let line = Line::from(vec![
Span::styled("", Style::default().fg(Color::Cyan)),
Span::styled(reply_preview, Style::default().fg(Color::Gray)),
Span::raw(" "),
Span::styled("", Style::default().fg(Color::Yellow)),
]);
(line, " Ответ (Esc отмена) ")
} else {
let short_preview: String = reply_preview.chars().take(15).collect();
let prefix = format!("{} > ", short_preview);
let line = render_input_with_cursor(
&prefix,
&app.message_input,
app.cursor_position,
Color::Yellow,
);
(line, " Ответ (Esc отмена) ")
}
} else {
// Обычный режим
if app.message_input.is_empty() {
// Пустой инпут - показываем курсор и placeholder
let line = Line::from(vec![
Span::raw("> "),
Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(" Введите сообщение...", Style::default().fg(Color::Gray)),
]);
(line, "")
} else {
// Текст с курсором
let line = render_input_with_cursor(
"> ",
&app.message_input,
app.cursor_position,
Color::Yellow,
);
(line, "")
}
};
let input_block = if input_title.is_empty() {
Block::default().borders(Borders::ALL)
} else {
let title_color = if app.is_replying() || app.is_forwarding() {
Color::Cyan
} else {
Color::Magenta
};
Block::default()
.borders(Borders::ALL)
.title(input_title)
.title_style(
Style::default()
.fg(title_color)
.add_modifier(Modifier::BOLD),
)
};
let input = Paragraph::new(input_line)
.block(input_block)
.wrap(ratatui::widgets::Wrap { trim: false });
f.render_widget(input, area);
}
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Режим профиля
if app.is_profile_mode() {
@@ -435,13 +285,13 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Режим поиска по сообщениям
if app.is_message_search_mode() {
render_search_mode(f, area, app);
modals::render_search(f, area, app);
return;
}
// Режим просмотра закреплённых сообщений
if app.is_pinned_mode() {
render_pinned_mode(f, area, app);
modals::render_pinned(f, area, app);
return;
}
@@ -492,7 +342,7 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
render_message_list(f, message_chunks[2], app);
// Input box с wrap для длинного текста и блочным курсором
render_input_box(f, message_chunks[3], app);
compose_bar::render(f, message_chunks[3], app);
} else {
let empty = Paragraph::new("Выберите чат")
.block(Block::default().borders(Borders::ALL))
@@ -503,7 +353,7 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Модалка подтверждения удаления
if app.is_confirm_delete_shown() {
render_delete_confirm_modal(f, area);
modals::render_delete_confirm(f, area);
}
// Модалка выбора реакции
@@ -513,381 +363,7 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
..
} = &app.chat_state
{
render_reaction_picker_modal(f, area, available_reactions, *selected_index);
modals::render_reaction_picker(f, area, available_reactions, *selected_index);
}
}
/// Рендерит режим поиска по сообщениям
fn render_search_mode<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Извлекаем данные из ChatState
let (query, results, selected_index) =
if let crate::app::ChatState::SearchInChat {
query,
results,
selected_index,
} = &app.chat_state
{
(query.as_str(), results.as_slice(), *selected_index)
} else {
return; // Некорректное состояние, не рендерим
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Search input
Constraint::Min(0), // Search results
Constraint::Length(3), // Help bar
])
.split(area);
// Search input
let total = results.len();
let current = if total > 0 {
selected_index + 1
} else {
0
};
let input_line = if query.is_empty() {
Line::from(vec![
Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(" Введите текст для поиска...", Style::default().fg(Color::Gray)),
])
} else {
Line::from(vec![
Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
Span::styled(query, Style::default().fg(Color::White)),
Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(format!(" ({}/{})", current, total), Style::default().fg(Color::Gray)),
])
};
let search_input = Paragraph::new(input_line).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow))
.title(" Поиск по сообщениям ")
.title_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
);
f.render_widget(search_input, chunks[0]);
// Search results
let content_width = chunks[1].width.saturating_sub(2) as usize;
let mut lines: Vec<Line> = Vec::new();
if results.is_empty() {
if !query.is_empty() {
lines.push(Line::from(Span::styled(
"Ничего не найдено",
Style::default().fg(Color::Gray),
)));
}
} else {
for (idx, msg) in results.iter().enumerate() {
let is_selected = idx == selected_index;
// Пустая строка между результатами
if idx > 0 {
lines.push(Line::from(""));
}
// Маркер выбора, имя и дата
let marker = if is_selected { "" } else { " " };
let sender_color = if msg.is_outgoing() {
Color::Green
} else {
Color::Cyan
};
let sender_name = if msg.is_outgoing() {
"Вы".to_string()
} else {
msg.sender_name().to_string()
};
lines.push(Line::from(vec![
Span::styled(
marker,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{} ", sender_name),
Style::default()
.fg(sender_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("({})", crate::utils::format_datetime(msg.date())),
Style::default().fg(Color::Gray),
),
]));
// Текст сообщения (с переносом)
let msg_color = if is_selected {
Color::Yellow
} else {
Color::White
};
let max_width = content_width.saturating_sub(4);
let wrapped = wrap_text_with_offsets(msg.text(), max_width);
let wrapped_count = wrapped.len();
for wrapped_line in wrapped.into_iter().take(2) {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(wrapped_line.text, Style::default().fg(msg_color)),
]));
}
if wrapped_count > 2 {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("...", Style::default().fg(Color::Gray)),
]));
}
}
}
// Скролл к выбранному результату
let visible_height = chunks[1].height.saturating_sub(2) as usize;
let lines_per_result = 4;
let selected_line = selected_index * lines_per_result;
let scroll_offset = if selected_line > visible_height / 2 {
(selected_line - visible_height / 2) as u16
} else {
0
};
let results_widget = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow)),
)
.scroll((scroll_offset, 0));
f.render_widget(results_widget, chunks[1]);
// Help bar
let help_line = Line::from(vec![
Span::styled(
" ↑↓ ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw("навигация"),
Span::raw(" "),
Span::styled(
" n/N ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw("след./пред."),
Span::raw(" "),
Span::styled(
" Enter ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw("перейти"),
Span::raw(" "),
Span::styled(" Esc ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::raw("выход"),
]);
let help = Paragraph::new(help_line)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow)),
)
.alignment(Alignment::Center);
f.render_widget(help, chunks[2]);
}
/// Рендерит режим просмотра закреплённых сообщений
fn render_pinned_mode<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Извлекаем данные из ChatState
let (messages, selected_index) = if let crate::app::ChatState::PinnedMessages {
messages,
selected_index,
} = &app.chat_state
{
(messages.as_slice(), *selected_index)
} else {
return; // Некорректное состояние
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Header
Constraint::Min(0), // Pinned messages list
Constraint::Length(3), // Help bar
])
.split(area);
// Header
let total = messages.len();
let current = selected_index + 1;
let header_text = format!("📌 ЗАКРЕПЛЁННЫЕ ({}/{})", current, total);
let header = Paragraph::new(header_text)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta)),
)
.style(
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
);
f.render_widget(header, chunks[0]);
// Pinned messages list
let content_width = chunks[1].width.saturating_sub(2) as usize;
let mut lines: Vec<Line> = Vec::new();
for (idx, msg) in messages.iter().enumerate() {
let is_selected = idx == selected_index;
// Пустая строка между сообщениями
if idx > 0 {
lines.push(Line::from(""));
}
// Маркер выбора и имя отправителя
let marker = if is_selected { "" } else { " " };
let sender_color = if msg.is_outgoing() {
Color::Green
} else {
Color::Cyan
};
let sender_name = if msg.is_outgoing() {
"Вы".to_string()
} else {
msg.sender_name().to_string()
};
lines.push(Line::from(vec![
Span::styled(
marker,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{} ", sender_name),
Style::default()
.fg(sender_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("({})", crate::utils::format_datetime(msg.date())),
Style::default().fg(Color::Gray),
),
]));
// Текст сообщения (с переносом)
let msg_color = if is_selected {
Color::Yellow
} else {
Color::White
};
let max_width = content_width.saturating_sub(4);
let wrapped = wrap_text_with_offsets(msg.text(), max_width);
let wrapped_count = wrapped.len();
for wrapped_line in wrapped.into_iter().take(3) {
// Максимум 3 строки на сообщение
lines.push(Line::from(vec![
Span::raw(" "), // Отступ
Span::styled(wrapped_line.text, Style::default().fg(msg_color)),
]));
}
if wrapped_count > 3 {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("...", Style::default().fg(Color::Gray)),
]));
}
}
if lines.is_empty() {
lines.push(Line::from(Span::styled(
"Нет закреплённых сообщений",
Style::default().fg(Color::Gray),
)));
}
// Скролл к выбранному сообщению
let visible_height = chunks[1].height.saturating_sub(2) as usize;
let lines_per_msg = 5; // Примерно строк на сообщение
let selected_line = selected_index * lines_per_msg;
let scroll_offset = if selected_line > visible_height / 2 {
(selected_line - visible_height / 2) as u16
} else {
0
};
let messages_widget = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta)),
)
.scroll((scroll_offset, 0));
f.render_widget(messages_widget, chunks[1]);
// Help bar
let help_line = Line::from(vec![
Span::styled(
" ↑↓ ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw("навигация"),
Span::raw(" "),
Span::styled(
" Enter ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw("перейти"),
Span::raw(" "),
Span::styled(" Esc ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::raw("выход"),
]);
let help = Paragraph::new(help_line)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta)),
)
.alignment(Alignment::Center);
f.render_widget(help, chunks[2]);
}
/// Рендерит модалку подтверждения удаления
fn render_delete_confirm_modal(f: &mut Frame, area: Rect) {
components::modal::render_delete_confirm_modal(f, area);
}
/// Рендерит модалку выбора реакции
fn render_reaction_picker_modal(
f: &mut Frame,
area: Rect,
available_reactions: &[String],
selected_index: usize,
) {
components::render_emoji_picker(f, area, available_reactions, selected_index);
}

View File

@@ -1,10 +1,16 @@
//! UI rendering module.
//!
//! Routes rendering by screen (Loading → Auth → Main) and checks terminal size.
mod auth;
pub mod chat_list;
mod compose_bar;
pub mod components;
pub mod footer;
mod loading;
mod main_screen;
pub mod messages;
mod modals;
pub mod profile;
use crate::app::{App, AppScreen};

View File

@@ -0,0 +1,8 @@
//! Delete confirmation modal
use ratatui::{Frame, layout::Rect};
/// Renders delete confirmation modal
pub fn render(f: &mut Frame, area: Rect) {
crate::ui::components::modal::render_delete_confirm_modal(f, area);
}

17
src/ui/modals/mod.rs Normal file
View File

@@ -0,0 +1,17 @@
//! Modal dialog rendering modules
//!
//! Contains UI rendering for various modal dialogs:
//! - delete_confirm: Delete confirmation modal
//! - reaction_picker: Emoji reaction picker modal
//! - search: Message search modal
//! - pinned: Pinned messages viewer modal
pub mod delete_confirm;
pub mod reaction_picker;
pub mod search;
pub mod pinned;
pub use delete_confirm::render as render_delete_confirm;
pub use reaction_picker::render as render_reaction_picker;
pub use search::render as render_search;
pub use pinned::render as render_pinned;

93
src/ui/modals/pinned.rs Normal file
View File

@@ -0,0 +1,93 @@
//! Pinned messages viewer modal
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::ui::components::{render_message_item, calculate_scroll_offset, render_help_bar};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
/// Renders pinned messages mode
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Извлекаем данные из ChatState
let (messages, selected_index) = if let crate::app::ChatState::PinnedMessages {
messages,
selected_index,
} = &app.chat_state
{
(messages.as_slice(), *selected_index)
} else {
return; // Некорректное состояние
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Header
Constraint::Min(0), // Pinned messages list
Constraint::Length(3), // Help bar
])
.split(area);
// Header
let total = messages.len();
let current = selected_index + 1;
let header_text = format!("📌 ЗАКРЕПЛЁННЫЕ ({}/{})", current, total);
let header = Paragraph::new(header_text)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta)),
)
.style(
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
);
f.render_widget(header, chunks[0]);
// Pinned messages list
let content_width = chunks[1].width.saturating_sub(2) as usize;
let mut lines: Vec<Line> = Vec::new();
for (idx, msg) in messages.iter().enumerate() {
if idx > 0 {
lines.push(Line::from(""));
}
lines.extend(render_message_item(msg, idx == selected_index, content_width, 3));
}
if lines.is_empty() {
lines.push(Line::from(Span::styled(
"Нет закреплённых сообщений",
Style::default().fg(Color::Gray),
)));
}
// Скролл к выбранному сообщению
let scroll_offset = calculate_scroll_offset(selected_index, 5, chunks[1].height);
let messages_widget = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta)),
)
.scroll((scroll_offset, 0));
f.render_widget(messages_widget, chunks[1]);
// Help bar
let help = render_help_bar(
&[
("↑↓", "навигация", Color::Yellow),
("Enter", "перейти", Color::Green),
("Esc", "выход", Color::Red),
],
Color::Magenta,
);
f.render_widget(help, chunks[2]);
}

View File

@@ -0,0 +1,13 @@
//! Reaction picker modal
use ratatui::{Frame, layout::Rect};
/// Renders emoji reaction picker modal
pub fn render(
f: &mut Frame,
area: Rect,
available_reactions: &[String],
selected_index: usize,
) {
crate::ui::components::render_emoji_picker(f, area, available_reactions, selected_index);
}

117
src/ui/modals/search.rs Normal file
View File

@@ -0,0 +1,117 @@
//! Message search modal
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::ui::components::{render_message_item, calculate_scroll_offset, render_help_bar};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
/// Renders message search mode
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Извлекаем данные из ChatState
let (query, results, selected_index) =
if let crate::app::ChatState::SearchInChat {
query,
results,
selected_index,
} = &app.chat_state
{
(query.as_str(), results.as_slice(), *selected_index)
} else {
return; // Некорректное состояние, не рендерим
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Search input
Constraint::Min(0), // Search results
Constraint::Length(3), // Help bar
])
.split(area);
// Search input
let total = results.len();
let current = if total > 0 {
selected_index + 1
} else {
0
};
let input_line = if query.is_empty() {
Line::from(vec![
Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(" Введите текст для поиска...", Style::default().fg(Color::Gray)),
])
} else {
Line::from(vec![
Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
Span::styled(query, Style::default().fg(Color::White)),
Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(format!(" ({}/{})", current, total), Style::default().fg(Color::Gray)),
])
};
let search_input = Paragraph::new(input_line).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow))
.title(" Поиск по сообщениям ")
.title_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
);
f.render_widget(search_input, chunks[0]);
// Search results
let content_width = chunks[1].width.saturating_sub(2) as usize;
let mut lines: Vec<Line> = Vec::new();
if results.is_empty() {
if !query.is_empty() {
lines.push(Line::from(Span::styled(
"Ничего не найдено",
Style::default().fg(Color::Gray),
)));
}
} else {
for (idx, msg) in results.iter().enumerate() {
if idx > 0 {
lines.push(Line::from(""));
}
lines.extend(render_message_item(msg, idx == selected_index, content_width, 2));
}
}
// Скролл к выбранному результату
let scroll_offset = calculate_scroll_offset(selected_index, 4, chunks[1].height);
let results_widget = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow)),
)
.scroll((scroll_offset, 0));
f.render_widget(results_widget, chunks[1]);
// Help bar
let help = render_help_bar(
&[
("↑↓", "навигация", Color::Yellow),
("n/N", "след./пред.", Color::Yellow),
("Enter", "перейти", Color::Green),
("Esc", "выход", Color::Red),
],
Color::Yellow,
);
f.render_widget(help, chunks[2]);
}

View File

@@ -1,4 +1,5 @@
use crate::app::App;
use crate::app::methods::modal::ModalMethods;
use crate::tdlib::TdClientTrait;
use crate::tdlib::ProfileInfo;
use ratatui::{